Compare commits

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

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

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

No new preamble version: 1.51.0 is not released yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 00:45:39 +01:00
Codeman maintainer 781e39022a Merge branch 'feat/local-model-skill-agents' into docs/local-models-wiki 2026-11-12 00:11:17 +01:00
Codeman maintainer ee0c9f4db9 Merge branch 'feat/local-model-harness-choice' into feat/local-model-skill-agents 2026-11-12 00:11:17 +01:00
Codeman maintainer b724f2d6ea Merge branch 'feat/local-model-library' into feat/local-model-harness-choice
# Conflicts:
#	src/web/CLAUDE.md
2026-11-12 00:11:10 +01:00
Codeman maintainer 9c2ba35f38 fix(local-model): a switch ends on the list of models, not on the one-button screen
Seen on a MacBook (M5 Pro, 2026-11-12): pressing Use on a model in "Models on this Mac"
ran the switch and then showed the screen that ends an install, "X is ready on this Mac"
with Start a session alone. The list was gone, so deleting the model just switched away
from meant closing the window and opening it again.

That screen is right after a download: the person installed a model and wants a session.
A switch is pressed IN the list by someone who is tidying it. A switch is a job without a
`model` step, so the window now tells the two apart from the job it just watched and ends a
switch on the whole installed screen: the list with "In use" on the other row, Add a model,
Stop and Remove.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 00:10:55 +01:00
Codeman maintainer db81d55fc9 fix(local-model): name a session without a name in the "still using this model" refusal
Seen on a MacBook (M5 Pro, 2026-11-12): with a worker of the codeman skill open on the
local model, Stop, Remove and a switch of models answered

    Sessions are still using this model: . Close them first.

A session an agent starts has no name of its own (a quick-start without sessionName), and
the refusal listed session.name. The tab of such a session shows the last folder of its
working directory, else the first eight characters of its id, so the sentence now uses the
same label (sessionLabel() beside sessionNamesUsingEndpoint()).

The sentence has shipped since 1.50.0 for Stop and Remove. This branch adds it to Use and
to the install of another model, which is where a person with local workers open meets it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 09:53:12 +01:00
Codeman maintainer 7bdef0d86a fix(local-model): a switch or a new model from a stopped model undoes the Stop
A job started from a model the person had stopped runs the runtime, and
the model it ends on is in use and running. The stored "stopped" flag
stayed set, so that model would not have started with the server again.
Both verbs now set it back once their job started, the way Start does.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:54:43 +01:00
Codeman maintainer 2f17b8067a docs(local-model): the models on this Mac, add, switch and delete
The guide, the API reference, the invariants and the two CLAUDE files
describe the library with one model in use: the state file's list, the
three verbs and their refusals, the two cached listings of the view, and
the window's list with its row actions.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:52:14 +01:00
Codeman maintainer 70dc42b248 feat(local-model): a library of models, with one in use
The local model window could hold one model. A second install was refused
(409 already-installed), and a model a Remove kept could never be freed
from the window again. The store can now hold several models, and exactly
one of them is the model in use: the one the endpoint record lists, the
runtime is started for and a session may be launched on.

Server
- LocalModelStateFile.library lists every model that passed the check
  (id, label, context, digest, size, coding agent). The service writes
  the entry when verify passes; the reducer carries it and remove drops
  it. An installed file of an earlier build counts its one model.
- POST /api/local-model/install is allowed from installed: it adds a
  model, and the earlier one stays. A replaced job no longer deletes a
  model of the library, only an unchecked or partial download.
- POST /api/local-model/use switches to a checked model: a job of the
  two steps verify and connect. The stored digest is compared before
  anything is loaded (model-changed otherwise). Nothing is downloaded.
- DELETE /api/local-model/models deletes one model by its name, through
  the runtime's own delete, never by a path, never the model in use.
- Adding and switching answer 409 sessions-open while a live session
  uses the endpoint. Until such a job ends installed, a session start is
  refused with the new code job-unfinished.
- The view carries library (the state's entries joined with what the
  running runtime lists: ready, changed, missing, unverified) and store
  (the size of the blob folder). Both are cached and refreshed in the
  background, never read on the one-second poll.
- With nothing installed, remove is offered while the store still holds
  models an earlier Remove kept.

Window
- The installed screen lists the models on this Mac with the context
  each was checked with, its state and its size on disk, with Use and
  Delete per row (Delete asks once, in the row) and Add a model.
- In the chooser a model that is on this Mac already says so and is
  switched to without a download or a confirm screen.
- A paused or failed job that has a checked model beside it shows the
  way back to that model.

Every route is admin only in multi-user mode, like the existing ones.

Not run on a Mac: the tests use fakes and the fake runtime, and the
listing, store size and delete were checked against a real Ollama 0.13.0
on Linux, not the pinned 0.40.15 build for macOS.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 23:52:14 +01:00
Codeman maintainer f62c1f7e2f fix(local-model): a view read while the job ends no longer shows paused
The install window polls GET /api/local-model every second. A read of the
state file that was still out while the job wrote its last state and let
go of this.job came back with the old `installing`, and settling that with
no job in the process read it as an interrupted job: `paused` with Resume
for one tick before `failed`. Under CI load this made
test/local-model-install-e2e.test.ts fail on a different failure case per
run (master 86502b69, PR #790).

currentState() now reads again when the job ended during the read. The job
writes before it lets go, so the second read has the final state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:51:07 +01:00
Ark0N 86502b6904 Merge pull request #789 from Ark0N/chore/desktop-cleanup
refactor(desktop): clean up the Mac app's code, no behavior change
2026-11-09 02:27:43 +01:00
Codeman maintainer 9a1bed449d docs(desktop): the probe's waits end at an answer, not quickly
An install wait can last 30 minutes, so "short" was the wrong word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:05:53 +01:00
Codeman maintainer 43a86eee39 docs(desktop): the app's size as it builds now
5.3 MB for the universal app and 3.5 MB for the DMG, measured on the Mac
mini from this branch; the README still had the first build's 4.6 and 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:03:56 +01:00
Codeman maintainer 404930d55e refactor(desktop): policy, logo and the connect page, simpler and the same
No behavior change: the connect page renders pixel-identical in eight
states (Chromium, before and after), posts the same messages, and passes
its jsdom and browser tests.

- policy.rs: the connect commands' camelCase fields come from serde's
  rename_all_fields instead of three per-field renames; parse_ipc's
  length and id checks are match guards like its other arms, with the
  message cap named (MAX_MESSAGE_LEN); new_window reads its URL through
  external_url, which is the same parse-and-http(s) check.
- logo.rs: the stubs for other platforms are one module, and Logo,
  Marker and recover are private to the file.
- connect.html: answers() is the one test for a server that answers
  (six copies), the running and needs-login rows of This Mac share their
  branch, prepareWizard, needsAgent, newWait and openTerminal each
  replace two or three copies, a finished install and a finished agent
  share their path, host() is displayAddress(), state.clock is in the
  state literal, three CSS rules that matched nothing are gone, and the
  state comments say what wait and gui hold now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:02:54 +01:00
Codeman maintainer 4bde072355 refactor(desktop): setup, install and probe code: shared helpers, honest docs
No behavior change.

- ProbeOutcome::version replaces two copies of the same match in setup.rs,
  and classify_response reads the status JSON with serde_json's indexing
  (same answer for every body, a missing key is Null).
- local_server::is_env_name is the one test for a variable name, used by
  the login shell's environment and by ~/.codeman/.env.
- setup.rs has basename() for its two command-line scans; the install
  scan no longer formats the script prefix once per word.
- agents.rs: refused() names the statuses that end the polling, and
  answer() takes the body it already has instead of a closure.
- Small simplifications in gui_install.rs (Run::matches, follow, the
  download error text) and a let-else in local_server::launch.
- Docs that no longer matched the code: probe.rs said the probe never runs
  in a loop, but the This Mac tab re-asks while it waits for an install
  (every 3 s) or for a starting server (every second); local_server.rs
  covered only Start; FIND_TIMEOUT and exec named one caller of several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:59:30 +01:00
Codeman maintainer ad5455044a refactor(desktop): main.rs says each repeated step once
No behavior change. What was written out two to four times now has one
name:

- AppWindow::new and AppWindow::hidden_for_download, window_builder,
  placed_position (the corner tao places, which the frame save and the
  pop-out cascade both read; cfg! instead of two attribute pairs),
  file_name_of and UNNAMED_FILE for the download toast.
- page_sender, which in_background, attach_to_run and both install
  starts use; plan_message and remote_install_failed for the connect
  page's answers; open_external_raw for the two OpenExternal commands;
  choosing() for the four phase checks.
- An accepted probe (Ok, or a password to change) runs one set of steps.
- macos::install runs inside build_webview, so no web view can be built
  without the dialog and login handlers.
- ProbeDone no longer carries the Authorization header beside the
  username and password it is made from; basic_auth makes it.
- Load::ConnectPage loses its manual flag, which was never true there.
- MIN_SIZE reads config's limits, the default username is
  setup::DEFAULT_USERNAME, and the repository and wiki addresses live
  in menu.rs.
- The module doc names the modules as they are now, and two doc comments
  that had slid onto the wrong item are back on theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:57:28 +01:00
Codeman maintainer db6fb53f5e refactor(desktop): move code out of main.rs and setup.rs into modules
Pure moves, no function body changed (git diff --color-moved shows it):

- terminal_install.rs: the Terminal and SSH installs (the scripts, the
  ssh arguments, open_in_terminal, copy_to_clipboard) and their tests,
  out of setup.rs, which keeps one subject: the Codeman on this Mac.
- login.rs: choose_login, update_keychain and their types and tests,
  out of main.rs.
- menu.rs: the menu bar, its ids and AppMenu, out of main.rs.
- policy.rs: loaded_on_commit and its test, a pure Phase decision like
  the rest of policy.

What changed besides the moves: module paths, pub on the moved items,
imports, the module docs, and the plutil test's temporary folder name,
which no longer draws on the Terminal scripts' counter. main.rs goes from
2312 to 1881 lines and setup.rs from 2160 to 1625.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:00 +01:00
Codeman maintainer f3087a12c0 docs(desktop): README tidy-up
The feature table no longer says v0.1, a few paragraphs that had grown
past the wrap width are rewrapped, and the Gatekeeper limit now starts
with the signed and notarized release DMG, so the ad hoc steps read as
what they are: for a build of your own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:00 +01:00
Codeman maintainer 0c3a70676e chore(desktop): one copy of base64 and dirs
ureq and wry already pull in base64 0.23 and dirs 7, so the app's own
base64 0.22 and dirs 6 were compiled beside them. The app now asks for the
versions already in the tree. base64 is declared as ureq declares it,
without the default SIMD engines, so the feature set compiled is the one
ureq had before. dirs 7 resolves config_dir and home_dir on macOS exactly
as 6 did (both go through dirs-sys 0.5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:55:00 +01:00
Codeman maintainer ccb76c3325 docs(readme): drop the Awesome AI Agents badge, smaller Mac download button
The "Featured in Awesome AI Agents" badge pointed at a list with far fewer
stars than this repository, so it added nothing. The Download for Mac button
under the tagline is a fifth smaller in both READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:24:21 +01:00
Codeman maintainer 01b4150672 docs(readme): a Download for Mac button at the top of both READMEs
The only DMG link above the fold was a plain link in the sentence under the
install command, easy to read past. Both READMEs now open with a large
"Download for Mac" button right under the tagline, pointing at the latest
release's Codeman.dmg, with one line on what the app is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:08:17 +01:00
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 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 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 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 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 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 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 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 ea23445ad7 ci(desktop): a rebuild by hand takes only a Codeman release tag on master
A dispatch with -f tag=<any tag> built that tag, and sign then ran the tag's
sign-macos.sh with the Developer ID key unlocked and the notary login in its
environment. Nothing checked the tag: xterm-zerolag-input@0.1.7 would have put
Codeman.dmg on that package's release, and a tag on a commit that never
reached master would have run unreviewed scripts with the secrets.

build and sign now each check their own checkout before anything else runs:
the tag must read codeman@x.y.z (or aicodeman@x.y.z, which a failed rename
leaves) and GitHub's compare of master with the checked-out commit must say
behind or identical. The check is inline, since a script from the checkout
would be the tag's own code. Release's own call (a push, not a dispatch) and
the branch dry run are unchanged.

The header says why a master-only Environment would not close this path, and
release.yml's macos-app job now says that any permission a called job asks
for must be granted there too, or Release fails at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:02:45 +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 d5273347c3 ci(desktop): build, sign, notarize and attach Codeman.dmg on every release
Every Codeman release now carries the Mac app as Codeman.dmg and
Codeman.dmg.sha256, under a name that never changes, so
https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always
serves the newest app.

.github/workflows/desktop-dmg.yml (workflow_call from Release with
tag codeman@<version>; workflow_dispatch with an optional tag) has three jobs,
so no job that holds a secret runs code from a crate:
- build (macos-26, contents: read, no secret, no cache) runs cargo test --locked
  and bundle-macos.sh --universal ad hoc, with CODEMAN_DESKTOP_INSTALLER_URL,
  CODEMAN_DESKTOP_INSTALL_BRANCH and SIGN_IDENTITY unset, checks both slices,
  the signature and that no test installer URL or branch is baked in, and
  uploads the app as a ditto zip.
- sign (macos-26, contents: read) refuses an artifact with an entry outside
  Codeman.app, a ".." or a symlink, imports MACOS_CERT_P12 into a keychain under
  RUNNER_TEMP with a random password, picks the one Developer ID Application
  identity by its hash and checks it belongs to APPLE_TEAM_ID, runs sign-macos.sh
  with the Apple ID login and uploads the signed pair as the codeman-dmg
  artifact. Its last step deletes the keychain and the decoded .p12 whatever
  happened before.
- attach (ubuntu, contents: write, no secret) runs only with a tag, on master
  (Release itself, or a rebuild started there) and in Ark0N/Codeman; it uploads
  the pair with --clobber and checks that the release's download link, and the
  Latest one when this release holds Latest, serve those bytes.
A run without a tag is the dry run: everything but attach.

release.yml reads the Codeman version from changesets' publishedPackages
(published alone is also true for an xterm-zerolag-input-only release) and runs
the macOS app job only for a Codeman release, passing it the five secrets by
name, never secrets: inherit. A new step puts Latest back on the newest
codeman@ release after every publish, so a release that moved only
xterm-zerolag-input no longer takes the badge and breaks the stable link. Both
new steps sit after the retag and only warn on failure, so npm, the tag and the
MCP publish are never held up by them.

desktop-ci.yml checks pull requests and master pushes that touch
packages/desktop: cargo fmt --check, clippy -D warnings, cargo test --locked and
bash -n under macOS's bash 3.2, on macos-26 with a read-only token. Both
workflows pin Rust 1.99.1, what the macos-26 image and the Mac mini ran on
2026-11-08.

docs/releasing.md: step 6 holds a site change that links the app until the DMG
check passes, step 7 names the new job, and a new section covers the secrets,
the check, a dry run, a rebuild and what to do when notarization fails.

actionlint 1.7.12 (with shellcheck 0.11.0) reports nothing on the three
workflows, and master's test/release-notes.test.ts assertions hold on the new
release.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:03:25 +01:00
Codeman maintainer 94912241bc feat(desktop): sign-macos.sh signs, notarizes and staples the app and its DMG
The release DMG is signed with the owner's Developer ID and notarized, so a
downloaded copy opens after macOS's usual confirmation instead of stopping at
Gatekeeper. The work is a script of its own, so CI and a Mac run it the same way:

  SIGN_IDENTITY=... NOTARY_PROFILE=<profile> \
    packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>

It re-signs the app with the hardened runtime, a secure timestamp and
macos/entitlements.plist, checks the signature is a Developer ID one with the
runtime flag, has notarytool notarize it and staples the ticket, builds the DMG
around the stapled app, signs it, notarizes and staples it too, and has spctl
assess both. Codeman.dmg and Codeman.dmg.sha256 reach <out-dir> only after every
check passed; a failed run removes an older pair there rather than leave one to
upload by mistake. A submission Apple does not accept stops the script with its
id and Apple's log. The notary login is either a notarytool keychain profile
(NOTARY_PROFILE, NOTARY_KEYCHAIN) or APPLE_ID, APPLE_APP_PASSWORD and
APPLE_TEAM_ID; `sign-macos.sh --check` tests that without doing anything.

The DMG layout (the app next to an /Applications link, UDZO, volume "Codeman")
moves to make-dmg.sh, which both scripts call, and gains a retry: hosted macOS
runners now and then fail hdiutil create with "Resource busy". bundle-macos.sh
keeps its SIGN_IDENTITY path as it was; its new --notarize checks the
environment before building, then hands the app to sign-macos.sh.
packages/desktop/README.md says the release DMG is notarized and how to do
the same by hand, and drops the CI build and notarization from its ideas list.

All three scripts are bash 3.2 clean, and shellcheck reports nothing. With the
macOS tools stubbed, sign-macos.sh was run under bash 3.2 through a full
signing, a notarization rejected for the app and for the DMG, two hdiutil
failures then a pass, three failures, a relative out-dir and every missing
setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:57:29 +01:00
Codeman maintainer 1e262b04d1 chore(release): take release.yml and docs/releasing.md from master as they are
This branch starts from feat/desktop-app (#763), which predates master's MCP
registry job and the release-notes body in release.yml and their pages in
docs/releasing.md. Both files are master's text verbatim here, so the Mac app
commit after this one changes them by additions only, and a rebase onto master
drops this commit as empty.

release.yml now names .github/workflows/publish-mcp.yml and
scripts/generate-release-notes.mjs, which exist on master but not on this
branch. The Release workflow runs only on pushes to master, so that matters
nowhere else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:57:11 +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 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
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 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 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
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 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 92797c2619 test(desktop): unit tests for main.rs's decisions and a browser test for the connect page
main.rs was the only source file without tests, and it held three decisions
with real failure modes:

- Which login a connect goes out with. The choice was a closure body inside
  the probe worker; it is now choose_login, tested for every row: typed wins,
  then the Keychain, then the local Codeman's own login, which is used only
  when no saved password was expected and only for the same username.
- What the Keychain keeps afterwards (update_keychain): saved or deleted by
  Remember, never a copy of the local server's login, never a password the
  server wants changed, and the old user's item deleted before the new save.
  The in-memory Keychain store moves out of credentials' test module so other
  test modules can use it.
- When a commit in the main window means the server page is loaded.

The connect page had only substring checks in the repository; the checks that
drove it lived in a scratch script. They are now
test/desktop-connect-page.browser.test.ts: Chromium, the page's real CSP, a
stubbed native side, five ordered walks through every state of both tabs
(first run, logins, failures, redirects, Start, the install wait, the SSH
install, layout, double clicks, keys, a starting server, a WebKit too old).
Listed in BROWSER_TEST_GLOBS, so the nightly browser suite runs it and
`npm test` does not.

Also pins the install one-liner the page shows to the one Copy copies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:54:49 +01:00
Codeman maintainer a42ce9c79f fix(desktop): a starting server, windows that open where expected, and the review's smaller findings
Found by the three-part review of this branch. Each was checked against the
source first; every decision that can run without a window has a unit test.

This Mac:
- A Codeman that runs but listens nowhere yet (the first seconds after login,
  a restart) was reported as "already running ... it listens at process N,
  which this app cannot open", and Start was refused for it. It is now its own
  state: the tab says "starting", looks again every second, and opens Codeman
  once it is up when a connect to this Mac had just been refused. The state
  table is a pure function with a test (setup::local_state).
- A server the app starts runs in the home folder, like one started by launchd
  or in a terminal. It ran in the app's own directory (`/` after a Finder
  launch), which Codeman then used as the default for sessions.
- Server detection accepts any entry word that `web` follows, so a dev server
  under a folder named codeman is seen and never gets a second server beside
  it.
- A login shell is polled from 5 ms up instead of every 100 ms, and the port
  after a launchd start every 100 ms instead of 500: both were pure waiting.
- The last line of stdout is no longer glued to the first line of stderr.

Addresses and policy:
- A tailnet name typed with a port gets http://, not https://. With a port it
  is Codeman's own listener; https there ended in a TLS error with no hint.
- within_base refuses `..%2F` and the other encoded ways out of the base path,
  so a sibling app behind a decoding proxy never sees the login.
- A status answer whose body stalls is a timeout, not "something else answers
  here".
- The folder pop-out pattern matches the two routes the server has.
- A malformed `window` in desktop.json no longer costs the server list.

Windows:
- Pop-outs step off the window in use. tao centres a window with no position,
  so every pop-out opened exactly on top of the one before.
- A window opened by a script no longer brings the app to the front: wry
  activated the application for it, whatever the window itself was told.
- The first-run window sits between the menu bar and the Dock. Centred in the
  whole display, its bottom was under the Dock on a 900 pt high one.
- The window frame is saved once after a move or resize ends, not once a
  second during the drag.

Also makes the connect page independent of TextEncoder and scrollIntoView, so
it runs in jsdom as well as in a web view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:40:53 +01:00
Codeman maintainer 116db3ba66 fix(desktop): a connect page that holds still, answers a press and never strands the user
Layout:
- The header no longer moves. The page was centred with align-items, so every
  change of the card's height moved everything (up to 150px between states),
  and a page taller than its window lost its top above the scroll origin,
  where no scrolling reaches it. The top margin is now fixed, the card grows
  downward and has a minimum height, and the page scrolls from its top. A
  short window gets a compact header.
- The status line sits right under the action it reports on, above the
  "not installed there yet?" section, takes no room while empty, and is
  scrolled into view in a small window. It was below the fold there.
- Looking at this Mac again keeps what was found on screen (the pane used to
  collapse to "Looking..." and grow back).

Feedback and keys:
- Buttons answer hover and press, every control shows keyboard focus, the
  primary button and the footer meet WCAG AA contrast, "Opening Codeman" keeps
  its spinner while the server page loads.
- Return opens or starts the Codeman on this Mac, Esc cancels a connect.
  Install always takes a click.
- The This Mac result is a live region, Forget buttons are named after their
  server, a long unbroken token wraps inside the card, the status text can be
  selected.

Correctness:
- The second click of a double click acts only on the control that took the
  first. It could connect to the server that slid under the pointer after a
  Forget, or flip "Connect automatically".
- An address, username or password too long for the app is refused with a
  reason. The app drops such a message without an answer, which left the page
  on "Connecting..." (the limits are pinned to policy.rs by a test).
- A connect answer goes to the tab that asked, a failed SSH install to its
  tab, and the login block no longer claims a saved password for another
  server.
- An automatic connect to the Codeman on this Mac no longer looks at this Mac
  first; a failed connect, Cancel or an error asks for that look.
- A login over plain http:// to another machine says that it is unencrypted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:26:57 +01:00
Codeman maintainer 01d3379b74 fix(desktop): recover a crashed page, name every dialog's frame, and four smaller review fixes
The findings of the 2026-11-05 review of this branch, each with a unit test
where the code can be tested without a window:

- A window whose web content process ended (a WebKit crash, or macOS taking
  the process back under memory pressure) stayed blank: wry answers WebKit's
  notice itself, which turns WebKit's own reload off. The window now loads its
  page again. A second end within 30 seconds sends the main window back to the
  connect page and closes a pop-out, so it cannot loop (policy::recovery).
- A JavaScript dialog from a frame nothing names (about:srcdoc, a data: or
  blob: URL under an opaque origin) showed the page's text as its bold first
  line. The first line is now always the app's own, the "embedded" flag is set
  when the origin names no host (it was the wrong way round), and every
  sub-frame counts as embedded, a trusted web tab on Codeman's origin included.
- The connect page lost the text of an error that needs the login fields: the
  tab switch cleared the status it had just set.
- Start for http://localhost:PORT missed the LaunchAgent (the compare was
  exact against 127.0.0.1) and ran a second, unsupervised server on its port.
- The path picker's preview Open link (api/filesystem/preview) did nothing: it
  was the one page Codeman opens in a window that the allowlist lacked.
- The port of a running install was lost whenever the Terminal script that
  wraps the installer came first in ps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 03:20:01 +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 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 8ba5134174 feat(desktop): native macOS app that runs, installs or connects to Codeman
Adds packages/desktop, a small native app (Rust: tao, wry/WKWebView, muda,
objc2) that opens Codeman in its own window. The universal app is about 5 MB
(3.2 MB as a DMG) and runs on macOS 11.3 or later.

- Connect screen with two tabs. This Mac finds the local Codeman and offers
  Open, Start or Install. Start uses launchd for a LaunchAgent install and
  codeman web -d otherwise. It never starts a second server while one runs,
  because two servers on one data dir share live sessions. Another machine
  connects by address (recent servers, Keychain logins) and can install
  Codeman there over SSH.
- Installs run Codeman's install.sh in Terminal from a self-deleting
  .command script. The installer needs a terminal for sudo, Homebrew and
  ssh. A local install uses --local --service and the first free port from
  3000, and the app opens Codeman once it answers.
- The local Codeman's login is read from its LaunchAgent or
  ~/.codeman/.env. It is sent only to 127.0.0.1 on that port, only while
  the user's own Codeman process holds it, and is never saved.
- Native JS dialogs (alert, confirm, prompt) and HTTP Basic challenge
  answers, both scoped to Codeman's own pages, so the page keeps working
  after a server restart. App cookies are cleared before every connect.
- Pop-outs through window.CodemanHost, with an allowlist (webview/ never
  opens a window), external links in the default browser, downloads to
  ~/Downloads, real full screen and window state that is remembered.
- The connect page refuses a WebKit older than Safari 16.4, which Codeman's
  terminal needs.

Codeman side: viewSubagentTranscript shows a toast when the pop-up is
refused instead of failing, with zh-CN strings and a test that pins them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:36:49 +01:00
167 changed files with 34607 additions and 840 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.49.0",
"version": "1.50.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+29
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
+72
View File
@@ -0,0 +1,72 @@
name: Desktop app
# Checks the native macOS app in packages/desktop on the pull requests and master
# pushes that change it: formatting, clippy and the unit tests, on macOS. No
# secret and a read-only token, because cargo runs the build script of every
# crate in Cargo.lock. The release build, signing and notarization live in
# desktop-dmg.yml.
on:
pull_request:
paths:
- 'packages/desktop/**'
- '.github/workflows/desktop-ci.yml'
push:
branches: [master]
paths:
- 'packages/desktop/**'
- '.github/workflows/desktop-ci.yml'
permissions:
contents: read
concurrency:
group: desktop-ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
# Keep in step with desktop-dmg.yml, which builds the release with it.
RUST_TOOLCHAIN: '1.99.1'
jobs:
check:
name: fmt, clippy, test
runs-on: macos-26
timeout-minutes: 45
defaults:
run:
working-directory: packages/desktop
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy --component rustfmt
rustup default "$RUST_TOOLCHAIN"
rustc --version
cargo --version
- name: Format
run: cargo fmt --check
# A test build bakes another installer in at compile time (option_env! in
# setup.rs), so neither variable may reach cargo.
- name: Clippy
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
cargo clippy --locked --all-targets -- -D warnings
- name: Unit tests
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
cargo test --locked
# /bin/bash on macOS is 3.2, the bash these scripts run under on a Mac.
- name: Scripts parse on bash 3.2
run: |
for script in scripts/*.sh; do
/bin/bash -n "$script"
done
+399
View File
@@ -0,0 +1,399 @@
name: macOS app
# Builds Codeman.app (Apple Silicon + Intel), signs it with the Developer ID,
# has Apple notarize the app and the DMG, and attaches Codeman.dmg and
# Codeman.dmg.sha256 to a Codeman release. The asset name never changes, so
# https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always
# serves the newest app.
#
# Release calls this when npm took a Codeman release, with tag codeman@<version>,
# and it builds the release commit. By hand, once this file is on master:
# gh workflow run desktop-dmg.yml # dry run of master: artifacts only
# gh workflow run desktop-dmg.yml --ref <branch> # dry run of a branch that carries this file
# gh workflow run desktop-dmg.yml -f tag=codeman@1.50.0 # rebuild that tag, replace its Codeman.dmg
# Only a run on master (or Release itself) attaches anything; every run leaves
# the signed Codeman.dmg as an artifact on its run page. A rebuild takes only a
# Codeman release tag whose commit is on master ("Check the tag", in build and
# in sign).
#
# A dispatch runs its ref's copy of this file with the secrets, as every
# repository secret reaches anyone with write access. A GitHub Environment
# limited to master would not narrow the rebuild path (the job's ref is master
# while it runs the tag's scripts) and would refuse the branch dry run above;
# the tag check is what keeps a rebuild on master's history.
#
# Three jobs, so no job that holds a secret runs code from a crate:
# - build runs cargo, and with it the build script of every crate in
# Cargo.lock. It has a read-only token and nothing else: no secret, no cache.
# - sign gets the built app as an artifact and runs only Apple's tools and this
# repository's scripts on it. It alone holds the signing and notary secrets.
# - attach has the one token that can write to a release, and no secret.
on:
workflow_call:
inputs:
tag:
description: Release to attach Codeman.dmg to (codeman@x.y.z); empty attaches nothing
type: string
required: false
default: ''
secrets:
MACOS_CERT_P12:
description: The Developer ID Application certificate with its private key, a base64 .p12
required: true
MACOS_CERT_PASSWORD:
description: The .p12's password
required: true
APPLE_ID:
description: Apple ID of the developer team, for the notary service
required: true
APPLE_APP_PASSWORD:
description: An app-specific password for that Apple ID
required: true
APPLE_TEAM_ID:
description: The team ID the certificate belongs to
required: true
workflow_dispatch:
inputs:
tag:
description: 'Release tag to rebuild and attach to, e.g. codeman@1.50.0. Empty: dry run, artifacts only'
type: string
required: false
default: ''
permissions:
contents: read
env:
# What the macos-26 image and the Mac mini that built every tested DMG both ran
# on 2026-11-08. Move it deliberately, together with desktop-ci.yml; Cargo.toml's
# rust-version is the floor.
RUST_TOOLCHAIN: '1.99.1'
# The commit to build: a rebuild by hand builds the tag it attaches to; Release
# (a push to master) and a dry run build the commit they ran on.
BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' && format('refs/tags/{0}', inputs.tag) || github.sha }}
jobs:
build:
name: Build Codeman.app
runs-on: macos-26
timeout-minutes: 60
permissions:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ env.BUILD_REF }}
persist-credentials: false
# A rebuild by hand builds the tag it names, and sign then runs that tag's
# scripts with the Developer ID key unlocked. So the tag must be a Codeman
# release (another package's tag would put Codeman.dmg on its release), and
# its commit must be on master's history, where those scripts were reviewed.
# aicodeman@ is the tag a failed rename leaves (Release's retag step).
# A tag can move between jobs, so build and sign each check their own
# checkout. Inline on purpose: a script from the checkout would be the
# tag's own code. Kept the same as the step in sign.
- name: Check the tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
if ! [[ $TAG =~ $TAG_RE ]]; then
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
exit 1
fi
SHA=$(git rev-parse HEAD)
# behind: an ancestor of master's tip; identical: the tip itself.
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
case "$STATUS" in
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
*)
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
exit 1
;;
esac
- name: Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \
--target aarch64-apple-darwin --target x86_64-apple-darwin
rustup default "$RUST_TOOLCHAIN"
rustc --version
cargo --version
# A release installs Codeman from getcodeman.com/install and master. A test
# build bakes another installer in at compile time (option_env! in setup.rs),
# so neither variable may reach cargo here, and setup.rs's tests pin the default.
- name: Unit tests
working-directory: packages/desktop
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH
cargo test --locked
- name: Bundle Codeman.app (signed ad hoc)
working-directory: packages/desktop
run: |
unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH SIGN_IDENTITY
./scripts/bundle-macos.sh --universal
- name: Check the build
working-directory: packages/desktop
run: |
BIN=dist/Codeman.app/Contents/MacOS/codeman-desktop
lipo "$BIN" -verify_arch arm64 x86_64
codesign --verify --strict dist/Codeman.app
strings -a "$BIN" > "$RUNNER_TEMP/strings.txt"
if grep -E 'raw\.githubusercontent\.com|test/desktop-' "$RUNNER_TEMP/strings.txt"; then
echo "::error::A test installer URL or branch is baked into the app"
exit 1
fi
grep -q 'https://getcodeman.com/install' "$RUNNER_TEMP/strings.txt"
# ditto keeps the bundle's modes, symlinks and extended attributes; an
# artifact of the bare folder would not (upload-artifact sets every file to
# 644). The artifact is a zip around this zip: download-artifact unpacks the
# outer one and leaves the ditto zip as it was.
- name: Archive Codeman.app
run: |
mkdir -p "$RUNNER_TEMP/unsigned"
ditto -c -k --sequesterRsrc --keepParent packages/desktop/dist/Codeman.app "$RUNNER_TEMP/unsigned/Codeman.app.zip"
- name: Upload the unsigned app
uses: actions/upload-artifact@v7
with:
name: codeman-app-unsigned
path: ${{ runner.temp }}/unsigned/Codeman.app.zip
if-no-files-found: error
retention-days: 7
overwrite: true
sign:
name: Sign and notarize
needs: build
runs-on: macos-26
# Two notary submissions; each usually takes minutes, and sign-macos.sh
# gives each at most 45.
timeout-minutes: 120
permissions:
contents: read
steps:
# For sign-macos.sh, make-dmg.sh and macos/entitlements.plist only. Nothing
# here builds or runs code from the app or its crates.
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ env.BUILD_REF }}
persist-credentials: false
# The same check as in build, on this job's own checkout, before anything
# here touches a secret. Kept the same as the step in build.
- name: Check the tag
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
if ! [[ $TAG =~ $TAG_RE ]]; then
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
exit 1
fi
SHA=$(git rev-parse HEAD)
# behind: an ancestor of master's tip; identical: the tip itself.
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
case "$STATUS" in
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
*)
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
exit 1
;;
esac
- name: Download the unsigned app
uses: actions/download-artifact@v8
with:
name: codeman-app-unsigned
path: ${{ runner.temp }}/unsigned
# The build job ran every crate's build script, so its zip is checked before
# this job, which holds the secrets, unpacks it: entries only under
# Codeman.app/ (and the __MACOSX/ folder where ditto keeps attributes), no
# "..", and no symlink, which the app never has and which could point an
# unpack or a later write outside the app.
- name: Unpack Codeman.app
run: |
ZIP="$RUNNER_TEMP/unsigned/Codeman.app.zip"
ENTRIES=$(zipinfo -1 "$ZIP")
LISTING=$(zipinfo "$ZIP")
if printf '%s\n' "$ENTRIES" | grep -vE '^(Codeman\.app|__MACOSX)/' \
|| printf '%s\n' "$ENTRIES" | grep -E '(^|/)\.\.(/|$)' \
|| printf '%s\n' "$LISTING" | grep '^l'; then
echo "::error::The build artifact holds an entry outside Codeman.app, a '..' or a symlink"
exit 1
fi
mkdir -p "$RUNNER_TEMP/app"
ditto -x -k "$ZIP" "$RUNNER_TEMP/app"
if [ -n "$(find "$RUNNER_TEMP/app" -type l)" ]; then
echo "::error::The unpacked app holds a symlink"
exit 1
fi
test -x "$RUNNER_TEMP/app/Codeman.app/Contents/MacOS/codeman-desktop"
# A keychain of its own under RUNNER_TEMP, with a random password, holds the
# Developer ID key for the length of this job. The last step deletes it and
# the decoded .p12, whatever happened before. All five secrets are checked
# here, so a missing one stops the job before any work.
- name: Signing keychain
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
for name in MACOS_CERT_P12 MACOS_CERT_PASSWORD APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
if [ -z "${!name}" ]; then
echo "::error::The repository secret $name is empty or not set"
exit 1
fi
done
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
P12="$RUNNER_TEMP/codeman-signing.p12"
trap 'rm -f "$P12"' EXIT
KEYCHAIN_PASSWORD=$(openssl rand -hex 32)
echo "::add-mask::$KEYCHAIN_PASSWORD"
# Line breaks out first: an encoder that wraps its output (GNU base64
# without -w0) leaves some that macOS's base64 refuses.
printf '%s' "$MACOS_CERT_P12" | tr -d '\r\n ' | base64 --decode > "$P12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
# Locks after six hours, not after the default five idle minutes, which
# a notarization wait can outlast.
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$P12" -k "$KEYCHAIN" -f pkcs12 -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign
rm -f "$P12"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
# First on the search list, ahead of the runner's own keychains.
SEARCH=()
while IFS= read -r line; do
line=${line#*\"}
line=${line%\"*}
if [ -n "$line" ]; then SEARCH+=("$line"); fi
done < <(security list-keychains -d user)
security list-keychains -d user -s "$KEYCHAIN" "${SEARCH[@]}"
# The identity by its SHA-1 hash, which no other certificate can match.
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN")
MATCHES=$(printf '%s\n' "$IDENTITIES" | grep '"Developer ID Application: ' || true)
COUNT=$(printf '%s' "$MATCHES" | grep -c . || true)
if [ "$COUNT" != 1 ]; then
printf '%s\n' "$IDENTITIES"
echo "::error::MACOS_CERT_P12 holds $COUNT valid Developer ID Application identities; it needs exactly one"
exit 1
fi
HASH=$(printf '%s\n' "$MATCHES" | awk '{ print $2 }')
IDENTITY=$(printf '%s\n' "$MATCHES" | sed 's/^[^"]*"\(.*\)"$/\1/')
case "$IDENTITY" in
*"($APPLE_TEAM_ID)") ;;
*)
echo "::error::The certificate in MACOS_CERT_P12 ($IDENTITY) is not one of team APPLE_TEAM_ID"
exit 1
;;
esac
echo "Signing as $IDENTITY ($HASH)"
echo "SIGN_IDENTITY=$HASH" >> "$GITHUB_ENV"
# The notary login goes to notarytool on its command line (sign-macos.sh's
# APPLE_ID path), in this job only, which already holds the signing key.
- name: Sign, notarize and staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packages/desktop/scripts/sign-macos.sh "$RUNNER_TEMP/app/Codeman.app" "$RUNNER_TEMP/out"
# A dry run's result: codeman-dmg on the run page (gh run download <run-id>
# -n codeman-dmg). attach takes it from here too.
- name: Upload Codeman.dmg
uses: actions/upload-artifact@v7
with:
name: codeman-dmg
path: |
${{ runner.temp }}/out/Codeman.dmg
${{ runner.temp }}/out/Codeman.dmg.sha256
if-no-files-found: error
retention-days: 30
overwrite: true
- name: Remove the signing keychain
if: always()
run: |
KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db"
if [ -e "$KEYCHAIN" ]; then
security delete-keychain "$KEYCHAIN" || true
fi
rm -f "$KEYCHAIN" "$RUNNER_TEMP/codeman-signing.p12"
attach:
name: Attach to the release
needs: sign
# Release runs on pushes to master, so github.ref is master both there and for
# a rebuild started by hand on master. A dry run from any other branch, or a
# fork, attaches nothing.
if: inputs.tag != '' && github.ref == 'refs/heads/master' && github.repository == 'Ark0N/Codeman'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Download Codeman.dmg
uses: actions/download-artifact@v8
with:
name: codeman-dmg
path: out
- name: Upload to the release
id: upload
working-directory: out
env:
GH_TOKEN: ${{ github.token }}
REQUESTED_TAG: ${{ inputs.tag }}
run: |
sha256sum -c Codeman.dmg.sha256
TAG=$REQUESTED_TAG
# Release's retag step ignores its own errors, so a release whose rename to
# codeman@ failed still carries the tag changesets gave it.
if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
case "$TAG" in
codeman@*) TAG="aicodeman@${TAG#codeman@}" ;;
esac
gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null
echo "::warning::Attaching to $TAG: there is no release $REQUESTED_TAG"
fi
gh release upload "$TAG" Codeman.dmg Codeman.dmg.sha256 --clobber -R "$GITHUB_REPOSITORY"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Check the download links
working-directory: out
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.upload.outputs.tag }}
run: |
EXPECTED=$(cut -d' ' -f1 Codeman.dmg.sha256)
check() {
curl -fsSL --retry 6 --retry-delay 10 --retry-all-errors -o "$RUNNER_TEMP/served.dmg" "$1"
echo "$EXPECTED $RUNNER_TEMP/served.dmg" | sha256sum -c --quiet -
echo "$1 serves this build"
}
check "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/Codeman.dmg"
LATEST=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
if [ "$LATEST" = "$TAG" ]; then
check "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/Codeman.dmg"
else
echo "::warning::Latest is $LATEST, so releases/latest/download/Codeman.dmg does not serve $TAG"
fi
+61
View File
@@ -16,6 +16,9 @@ jobs:
pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
# Set only when aicodeman itself was published: `published` is also true for
# a release that moved only xterm-zerolag-input.
codeman_version: ${{ steps.codeman.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
@@ -89,6 +92,38 @@ jobs:
git tag -d "$OLD_TAG" 2>/dev/null || true
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
# The macOS app job below runs only for a Codeman release, so it reads the
# version from the list of packages this run published. Never fails the job:
# npm has published by now, and a bad list only means no app build.
- name: Codeman version published by this run
id: codeman
if: steps.changesets.outputs.published == 'true'
env:
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
run: |
VERSION=$(printf '%s' "$PUBLISHED_PACKAGES" | jq -r '.[] | select(.name == "aicodeman") | .version' || true)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Codeman version published: ${VERSION:-none (no macOS app build)}"
# releases/latest/download/Codeman.dmg (the Mac app's stable link) serves
# whichever release holds Latest. GitHub gives Latest to the release created
# last, and a publish that moved only xterm-zerolag-input creates one the
# retag above never touches, so Latest goes back to the newest Codeman
# release after every publish. A failure warns and leaves npm, the tag and
# the MCP publish alone.
- name: Keep Latest on the Codeman release
if: steps.changesets.outputs.published == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -p "require('./package.json').version")
RELEASE_ID=$(gh release view "codeman@${VERSION}" --json databaseId -q .databaseId 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
echo "::warning::No codeman@${VERSION} release to mark Latest"
elif ! gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" -f make_latest=true > /dev/null; then
echo "::warning::Could not mark codeman@${VERSION} Latest"
fi
# Its own job: this one alone holds id-token, and a registry failure shows as itself
# without touching the npm publish or the tag above.
mcp-registry:
@@ -99,3 +134,29 @@ jobs:
contents: read
id-token: write
uses: ./.github/workflows/publish-mcp.yml
# Builds, signs and notarizes the Mac app and attaches Codeman.dmg to the
# release (.github/workflows/desktop-dmg.yml), only when Codeman itself was
# published. Its own job, so a failed DMG shows as itself and never touches
# npm, the tag or the MCP publish; `gh workflow run desktop-dmg.yml -f
# tag=codeman@<version>` reruns it. It gets the five secrets it uses and no
# other: never `secrets: inherit`, which would hand it NPM_TOKEN.
macos-app:
name: macOS app
needs: release
if: needs.release.outputs.codeman_version != ''
# The ceiling for the called jobs; only its attach job asks for write. A job in
# desktop-dmg.yml that asks for a permission not granted here (id-token or
# attestations for a provenance attestation, say) makes GitHub refuse this
# whole Release run at startup, npm publish included: widen both together.
permissions:
contents: write
uses: ./.github/workflows/desktop-dmg.yml
with:
tag: codeman@${{ needs.release.outputs.codeman_version }}
secrets:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+37
View File
@@ -1,5 +1,42 @@
# 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
+9 -8
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.49.0 (must match `package.json`)
**Version**: 1.50.0 (must match `package.json`)
## Project Overview
@@ -104,6 +104,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **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. (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, 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 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`
@@ -193,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<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
**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime|agent`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalLine truncated
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
@@ -234,17 +235,17 @@ 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** (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. 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` 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. ⚠️ The security argument has two halves. The lookup only ever answers a folder strictly below `workingDir`, 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 `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ 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. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#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'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).
@@ -327,7 +328,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `local-model.json` (local model install: state, step, model, port, pid, digest, the 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`), `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) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three 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 checked models of the library, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**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.
@@ -392,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`). ⚠️ **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`). → [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.
+27 -3
View File
@@ -8,6 +8,12 @@
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/Download_for_Mac-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="Download Codeman for Mac (Codeman.dmg)" width="354"></a>
<br>
<sub>The native Mac app: signed and notarized, Apple Silicon and Intel, macOS 11.3 or later. <a href="#on-a-mac-the-codeman-app">What it does</a></sub>
</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
@@ -17,7 +23,6 @@
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
</p>
<p align="center">
@@ -43,6 +48,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
@@ -97,6 +104,21 @@ 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>
@@ -776,13 +798,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`). |
| 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.
@@ -850,7 +874,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>`.
---
+27 -3
View File
@@ -8,6 +8,12 @@
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD_Mac_%E7%89%88-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="下载 Mac 版 Codeman(Codeman.dmg)" width="281"></a>
<br>
<sub>原生 Mac 应用:已签名并经过 Apple 公证,支持 Apple 芯片和 Intel,macOS 11.3 或更高版本。<a href="#在-mac-上codeman-应用">它能做什么</a></sub>
</p>
<p align="center">
<a href="README.md">English</a> &bull; <strong>简体中文</strong>
</p>
@@ -21,7 +27,6 @@
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
</p>
<p align="center">
@@ -45,6 +50,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 小时以上的无人值守运行
@@ -99,6 +106,21 @@ bash docker/Start-Codeman.sh
详见下文[多用户模式](#多用户模式可选启用)。
### 在 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>
@@ -775,13 +797,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`。
@@ -848,7 +872,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.49.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.49.0",
"version": "1.50.0",
"transport": {
"type": "stdio"
},
+1
View File
@@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [
'test/fullscreen-tab-switch-capture.browser.test.ts',
'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',
];
+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:
+69 -22
View File
@@ -988,11 +988,12 @@ 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, 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`.
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`)
## 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
@@ -1003,17 +1004,54 @@ EnterWorktree moved the running session:
```
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. `path` is in `workingDir`'s
spelling. Like `paneCwd` it is never restored after a restart and never a confinement root.
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 that worktree instead of at `workingDir`. The response's `root` names the folder
`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`. A `path` in the response stays
relative to `workingDir` either way, so it works unchanged on `file-content` and `file-raw`,
while a `q` containing `/` is matched against paths relative to the folder walked. The response
also carries `worktree`, the same `{ name, path }`, whenever the session has a `liveWorktree`,
whichever folder was walked and with or without `root=live`.
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
@@ -1678,7 +1716,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
## Local model
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Eight verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, install one more agent CLI beside an installed model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet.
@@ -1687,11 +1725,14 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a
| Method | Path | Body | Notes |
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. |
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. With a model installed it adds another one: the earlier model stays on disk, and the new one is the model in use when the job ends. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed` (this model at this context is the one in use), `size-changed`, `sessions-open`. |
| `POST` | `/api/local-model/use` | `{ modelId }` | Switches to a model of `install.library` whose `actions` list `use`. A job of the two steps `verify` and `connect`; nothing is downloaded. `400 INVALID_INPUT` when the model is not a checked model of the library, or no coding agent for it is installed. `409`: `job-running`, `wrong-state` (nothing is installed), `already-installed`, `sessions-open`. |
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. |
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. |
| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. |
| `POST` | `/api/local-model/agent` | `{ cli }` | Installs one more agent CLI beside an installed model and answers at once; the installer runs on in the server and `install.harnesses` says how it stands. `cli` is a row of `install.harnesses` whose `actions` list `install`; the server runs that row's `installCommand`, the release's own stock command, never text from the request. It is not a job: `install.state` stays `installed` whatever the installer does, sessions are not refused, and no live session is looked at. An agent that can already be started answers `200` with the view as it is. `400 INVALID_INPUT` for a CLI that is not a row of `install.harnesses`, or one that one command does not make startable (`setup-needed`). `409`: `job-running` (a job, or another agent install, is running), `wrong-state` (nothing is installed). |
Every verb answers `{ success: true, data: <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:
@@ -1709,9 +1750,9 @@ Every verb answers `{ success: true, data: <install> }` with the `install` objec
}
```
`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
`data.reason` is `job-running`, `already-installed` (the model is the one in use already), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model), `wrong-state`, `model-active` (a delete of the model in use: switch first, or remove everything) or `runtime-not-running` (a delete while the runtime is down: start it first). A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the feature names, the `507 OPERATION_FAILED` of a first install on a full disk and the `500 OPERATION_FAILED` of a delete the runtime refused carry their sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
@@ -1737,22 +1778,26 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run.
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run;
- `library?`: the model at this context is a checked model on this machine already (see `install.library`). `active`: it is the model in use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded. Absent otherwise.
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
- `install`: where the install stands and what can be done about it.
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed.
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`.
- `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue.
- `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated.
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen.
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. With `installed`, `cli` is the DEFAULT agent CLI, the one a client that names none starts: the agent of the installation while it can be started, else the first row of `harnesses` that can. `port`: the runtime's loopback port, once chosen.
- `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`.
- `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`.
- `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli`.
- `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli` or with any row of `harnesses` that offers `start-session`.
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
- `folder`: the absolute folder everything of this feature is kept in.
- `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
- `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action.
- `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds.
- `harnesses[]`: the agent CLIs a session on the model in use can be started with, the default one first, then by the catalogue's rank. The agent is chosen per session: a session is started with `mode` set to a row's `cli` and `customModel: { endpointId, modelId }`. Empty in every state but `installed`. Each row: `cli`, `cliLabel`, `default` (true on the row `install.cli` names), `status`, `installCommand?` (with `installable` and `installing`: the literal command `POST .../agent` runs), `slowFirstTurn?` (the first answer takes minutes), `readyMark?` (a literal without white space that the agent's composer draws once it takes input: what a script waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt; absent for an agent with no measured mark. A mark says the composer is there, not that nothing else is: DeepSeek Harness with dsh-TUI 0.19 opens a setup guide whose cursor is its `❯` too until a person has finished it once, so a script reads the pane after the mark), `error?` (`{ message, detail? }`: the last install of this agent through `POST .../agent` failed; kept in memory only) and `actions[]` (`start-session`, `install`). `status` is `ready` (it can be started, the answer the Run menu gets), `installable` (not on this machine, and `POST .../agent` installs it), `installing` (that install is running; the model stays as it is) or `setup-needed` (not startable, and one command does not make it so: DeepSeek Harness without a terminal profile, or a command that needs an administrator; set it up from the Run menu or a terminal).
- `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them), `install-agent` (only while a row of `harnesses` offers `install`) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs, nothing blocks a session and, when `harnesses` is known, at least one agent CLI can be started).
Error codes (`install.error.code`), each with one next step:
@@ -1762,9 +1807,11 @@ Error codes (`install.error.code`), each with one next step:
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
| `other-port` | `port-in-use` |
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) |
| `remove` | `model-changed` (the model's digest is no longer the one stored when it was checked; a session on it is refused, and so is a switch to it) |
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one.
Two codes are only ever the reason of a refused session start, never on `install.error`: `runtime-not-running` (the person stopped the runtime) and `job-unfinished` (a job that changes the model in use is running or stopped: nothing is started until it ended `installed` again).
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, while a job that changes the model in use is running or stopped, when the model's digest changed, or for a model other than the one in use.
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
+37 -11
View File
@@ -167,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).
@@ -229,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.
@@ -425,13 +425,13 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
**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.
@@ -714,17 +714,27 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
**The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP.
**The coding agent is chosen per session.** The launch check (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent, so any CLI with a custom model recipe could always be started on `local-ollama` from the Run menu. What the feature adds is the list: `install.harnesses` (`buildHarnessRows()`, pure) is every catalogue harness that is not gated, has a `HarnessFact`, works at the model's context and accepts its name, each `ready`, `installable`, `installing` or `setup-needed`. ⚠️ `install.cli` stays the DEFAULT agent for a client that names none (the 1.50.0 skill reads only that field): the agent of the installation while it can be started, else the first row that can, so it never names an agent that is gone while another is there. ⚠️ `start-session` is listed only while a row is `ready`, once the rows are known; rows that could not be built (the registry threw) are "unknown", never "none", and a session is still offered. ⚠️ A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its stock command installs the launcher and leaves it with nothing to launch, and the profile it needs is third-party code behind the privileged grant of `POST /api/deepseek/install-profile`. The rule reads the entry's data, never its id, and such an agent is offered only once the Run menu can start it. ⚠️ `POST /api/local-model/agent` (`installAgent()`) is NOT a job and NOT an event of the reducer: a job would put a working model into `failed` on an `npm` error and refuse every session until it was cleared. It runs `installCodingAgent()`, the function the `agent` step runs (same slot, same compiled command, same audit line), in the background; the state file never hears of it, the outcome lives in memory (`agentInstall`, `agentFailures`), one installer at a time, and `shutdown()` aborts it. A harness row may carry `readyMark` (a literal the composer draws once it takes input, for a script's `wait-output`): only a MEASURED mark goes there, the registry's `composerReadyMark` stands in when the row has none, and Pi has none on purpose (its composer rules are painted before it takes Enter, measured on 1.5.2).
**The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [<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.
**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request.
⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`).
⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again.
**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed.
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs, the agent install and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
### Approvals Inbox
@@ -800,23 +810,37 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
⚠️ **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 path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` listing; the returned `path`s stay relative to `workingDir` either way), 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` 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.
**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` checks 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.
⚠️ **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.
⚠️ `liveWorktree` 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. Serving a worktree outside the folder (`git worktree add ../feature`) would need a second root, so those stay unfollowed, and so does a worktree of a session started below the repository's top, because Claude Code puts it at the top, outside the session's folder.
⚠️ 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 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 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:
Two surfaces do not move with it. 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). Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
- 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
@@ -1387,6 +1411,8 @@ On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt`
⚠️ `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
+7
View File
@@ -578,6 +578,13 @@ automatically). Results:
- **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply
came back through the endpoint.
Pi's model entry carries the endpoint's context length when one is known
(`contextWindow`, the full window, and `maxTokens`, a quarter of it and
8,192 at most). Without it Pi takes an unknown model for a 128,000-token
one (its footer reads `/128k`) and asks for 16,384 reply tokens, so on a
smaller window it never compacts before the server's real window is full.
Measured on pi 1.5.2: with both fields `pi --list-models` shows the window
and the request carries `max_tokens: 8192`.
- **Codex** — the config is structurally correct, and against a llama-swap
server that DOES answer `/v1/responses` (confirmed live: a plain,
no-tool-call chat turn returned a real reply), the picture is more
+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
+149 -16
View File
@@ -4,7 +4,9 @@ Codeman can set up a coding model that runs on the Mac the server runs on: no ac
no API key, and the session's prompts and files go to a model on this Mac rather than a
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
shows what fits this Mac, installs the choice and ends on a coding agent session that
runs on it.
runs on it. The same window then lists the models that are on this Mac with what each takes
on disk, adds another one, switches between them and deletes one
([The models on this Mac](#the-models-on-this-mac)).
**Experimental.** The routes and their payloads may change in any release
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
@@ -103,7 +105,9 @@ for a row it does not know. Its answer for each memory size is then pinned in
## What an install does, in order
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
server then runs one job of five steps. The window polls `GET /api/local-model` once a
server then runs one job of five steps. The same job adds a second model to an installed one
("Add a model"): the runtime is there already, so it downloads the model alone, and the
earlier model stays on disk. The window polls `GET /api/local-model` once a
second; the job runs in the server, so the window can be closed.
| Step | What it does |
@@ -206,6 +210,66 @@ This step does not need the `cliManagementEnabled` setting. That setting gates t
CLI management surface; this path can only ever run one vetted command for one catalogue
row.
### Which coding agents, and what each was measured with
The coding agent is chosen **per session**. An installed model lists every agent it can be
run with (`install.harnesses` of the GET), and the window shows them as chips under "Start a
session with": the agent the model was installed with is the default, and the choice is
remembered on the device. The launch check asks about the endpoint and the model, never about
the agent, so a session with one agent and a session with another run on the same model.
| Agent | First request (its fixed prompt) | Installed from the window | What was seen, and where |
| ---------------- | ------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenCode | about 8,100 tokens, 11 tools (v1.19.5; more with user-level skills) | yes | A tool round trip on a Mac with a real model (the catalogue's tested entry), and on a scripted endpoint for v1.19.5 |
| Claude Code | about 36,000 tokens, so the first answer takes minutes | yes | Shipped in 1.50.0; not measured again for this table |
| Pi | 2,096 tokens, 4 tools (v1.5.2) | yes | A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk |
| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2); 6,983, 27 tools (0.2.2-rc.1) | **no** | A tool round trip on a scripted endpoint only, on both versions. Against a real model the launch and the model name reached the runtime, and no turn finished |
How to read the last column: the runs of 2026-11-10 were made on **Linux**, through Codeman's
own custom model launch, against Ollama 0.13.0 with a 1.7B model on a CPU ("a real model") or
against a scripted endpoint that replays that runtime's stream of a tool call. A scripted run
proves the agent's side of the protocol, not what a model does with its prompt. None of them
ran on a Mac or on the pinned runtime. Every agent sent the `name:tag` model name unchanged.
Three things follow from how the agents differ:
- **Pi is told the model's window.** Without it Pi takes an unknown model for a
128,000-token one and never compacts before the real window is full. Codeman now writes the
endpoint's context length into the model list it gives Pi (the custom model feature as a
whole, not only this window). Two things remain: a custom model session gives Pi a home
directory of its own, so Pi fetches its two search tools again at every start (it needs the
network for that; `PI_OFFLINE=1` skips it), and Pi asks "Trust project
folder?" in every such session when a folder above the working directory holds agent
skills, because the answer is stored in the home it does not keep.
- **DeepSeek Harness is a launcher.** `dsh` starts nothing by itself: a session needs a
terminal profile, which is third-party code with its own consent in the Run menu. Its
install command installs the launcher only, so this window never installs it. It is listed
as "Needs setup" until the Run menu can start it, and as a normal agent from then on. It is
the one agent of the four besides Claude Code that reports the end of a turn. Its custom
endpoint recipe itself was verified with a real model on a llama.cpp server
([custom-model-endpoints.md](custom-model-endpoints.md)); what is missing is a finished turn
on this feature's runtime. On today's version (0.2.2-rc.1 with dsh-TUI 0.19.0) two more
things were seen: a setup guide opens in every session until a person has finished it once
(a person in a tab presses through it; a script must not, see the codeman skill), and its
transcript file was renamed, so Codeman's response viewer and `last-response` are empty for
a dsh session on that version until the reader learns the new name.
- **OpenCode and DeepSeek Harness send a second, small request** beside the first prompt (a
title, a side question). The runtime answers one request at a time, so it queues.
### Installing one more agent beside an installed model
An agent that is not on the Mac has "Not installed" on its chip. Selecting it shows the
literal install command, and the main button becomes "Install <agent>". That click is the
consent; `POST /api/local-model/agent` then runs the same stock command under the same rules
as the `agent` step above (catalogue row only, compiled command only, admin only, audited,
one installer at a time).
It is **not a job**. The state stays `installed` whatever the installer does: the model stays
loaded, sessions are not refused, and a session with another agent can be started while it
runs. A failed install shows the sentence and the installer's last lines under the chip, and
leaves everything else as it was. The outcome is kept in memory only, so a restart of the
server forgets a failure (the chip then simply says "Not installed" again).
### The connection
Two writes outside the local model folder, both undone by Remove:
@@ -240,14 +304,17 @@ Switching the setting on also starts that five-minute sweep for every other save
## Starting a session
"Start a session" starts the coding agent on the endpoint `local-ollama` through the
"Start a session" starts the selected coding agent on the endpoint `local-ollama` through the
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
checks, in this order:
- no job that changes the model in use is running or stopped: until an install or a switch
ended, nothing is started ("The model on this Mac is being changed and is not ready");
- the runtime child Codeman started is alive (whatever else answers on the port is not
ours);
- the digest the runtime reports for the installed model is the one stored at install time;
- the model asked for is the one that was installed and checked.
- the digest the runtime reports for the model in use is the one stored when it was checked;
- the model asked for is the model in use. Another model that is on this Mac is not started
on the endpoint: switch to it in the window first.
That check runs wherever a new agent process comes to be on the endpoint: a session created
with the model, a model applied to a running session (which restarts its agent), the attach
@@ -266,29 +333,86 @@ The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-c
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
is installed or why nothing can run, with the coding agents the model can be run with and the
models on the Mac; `spawn_worker <name> local` starts the runtime when it was
stopped (as Start does), then starts the default coding agent (`install.cli`), or with
`local:<agent>` (`local:pi`, `local:deepseek`) another one that is ready, on `local-ollama` through the same
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
answer back from a file it writes in its own folder. The skill never installs a model and never
answer back from a file it writes in its own folder. The skill never installs a model or a coding agent and never
answers a confirmation, the runtime still answers one request at a time, and while a worker it
opened is live, Stop and Remove are refused until it is closed.
## The models on this Mac
The model store can hold several models. **One of them is the model in use**: the one the
endpoint record `local-ollama` lists, the runtime is started for and a session can be
started on. The runtime holds one model in memory at a time (`OLLAMA_MAX_LOADED_MODELS=1`),
so this is what the Mac can really do, and it keeps every rule above: one record, one
model, one stored digest per model.
The installed screen lists what is on this Mac, the model in use first:
| A row says | It means |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| In use | The model sessions start on |
| Ready | Codeman downloaded and checked it. **Use** switches to it |
| Not checked | The runtime lists it and Codeman never checked it: a model an earlier Remove kept, or one another program pulled through the runtime's port. It can only be deleted |
| Changed | It was checked, and the runtime now reports another digest under its name. It is never started; delete it and install it again |
| Missing | It was checked, and the runtime no longer lists it. **Delete** drops the entry |
Each row shows the context the model was checked with and its size on disk, as the runtime
reports it. Under the list is what all downloads take together: the size of the files in
`local-model/models/blobs`, so layers two models share are counted once and an interrupted
download is included. The window asks the runtime for the list at most every five seconds
and measures the store at most every thirty, never on every poll.
- **Add a model** opens the chooser. A model that is not on this Mac is installed like the
first one, with its sizes confirmed; when the job ends it is the model in use, and the
earlier model stays in the list. A row that is on this Mac already says so and is switched
to, without a download and without a confirm screen.
- **Use** switches to a checked model. It is a job of two steps, `verify` and `connect`:
the runtime is restarted with the context the model was checked with, the model is loaded
and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the
stored one (else the switch fails with `model-changed` before the model is loaded), and
the endpoint record is rewritten. The default coding agent becomes the one the model was
checked with when it is still installed, else the one of the installation, else any other
agent that is on the Mac and can run the model.
- **Delete** removes one model through the runtime's own delete, after one question in the
row. The request carries the model's name, never a path, and the server deletes only a
name of its own listing. The model in use cannot be deleted: switch first, or Remove. It
needs the running runtime, so a stopped model shows no Delete until Start.
Adding a model and switching are refused while a session uses the model ("Sessions are
still using this model"), like Stop: their coding agents were started with the other model's
name, and the runtime would swap models on every request.
While a job that started from an installed model runs, or after it stopped (paused or
failed), no session is started: the checks above want a finished installation, and the
refusal says so (`job-unfinished`). The window
then lists the checked models with **Use**, which is the way back to the earlier one. Going
back deletes what the stopped job left of a model that never passed its check, the way a new
install does; a checked model is never deleted that way.
## Stop, Start, Remove
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
that.
that, and so does a switch to another model or the install of one: the job starts the
runtime, and the model it ends on is in use.
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
only when this feature switched it on and no other endpoint is saved, and deletes the
runtime and its private home. The downloaded model is deleted only when asked. The coding
agent stays. While a live session uses the model, Remove answers with the session names
instead.
runtime and its private home. The downloaded models (all of them) are deleted only when
asked. The coding agent stays. While a live session uses the model, Remove answers with
the session names instead.
- **Models a Remove kept** are not lost space: with nothing installed the window says how
much they still take and offers "Delete them", and after a new install they show in the
list as "Not checked", each with its own Delete.
## Where everything is
All under the data directory (`~/.codeman`, or the instance's own):
```
local-model.json the state file (mode 0600)
local-model.json the state file (mode 0600), with the list of checked models
local-model/
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
@@ -313,9 +437,12 @@ in App Settings.
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
| `idle` | stays idle | refused | install |
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
| `paused` | stays paused | refused | resume, install, remove |
| `failed` | stays failed, same error | refused | resume, install, remove |
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
| `paused` | stays paused | refused | resume, install, use, delete a model, remove |
| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove |
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove |
`use` and "delete a model" are listed only while a row of the list offers them, and in
`idle` "remove" is offered only while the model store still holds something.
One job at a time. A job a server restart interrupted comes back paused and never continues
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
@@ -357,6 +484,12 @@ or `python3`.
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
- **The model stays in memory for ten minutes** after the last request, then the runtime
unloads it.
- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch
restarts it and loads the other model (seconds to a minute). Close the sessions on the
model first: the window refuses the switch while one is open.
- **The disk figures are the runtime's.** A row's size is what the runtime reports for the
model, and the total is the size of its blob files. Neither was compared with Finder on a
Mac yet.
## Keeping the catalogue current
+34 -1
View File
@@ -42,7 +42,10 @@ When user says "COM":
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release and the MCP registry publish). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
A site change that links the Mac app (`Codeman.dmg`, `/mac`) deploys only after step 7 showed the `macOS app` job green and the [DMG check](#the-mac-app-codemandmg) answered 200: the DMG reaches the release well after the npm publish, and the download link answers 404 until then.
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release, the MCP registry publish and, when Codeman itself was published, the `macOS app` job that attaches `Codeman.dmg`). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them.
@@ -50,6 +53,36 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
## The Mac app (Codeman.dmg)
Every Codeman release carries the native macOS app (`packages/desktop`) as `Codeman.dmg` plus `Codeman.dmg.sha256`. The name never changes, so https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always serves the newest app, while each release keeps its own copy at `https://github.com/Ark0N/Codeman/releases/download/codeman@<x.y.z>/Codeman.dmg` (link that one in release notes). The app keeps its own version (`packages/desktop/Cargo.toml`), so the DMG of Codeman 1.50.0 holds app 0.1.0.
**How it gets there.** When npm took a Codeman release (not one that moved only xterm-zerolag-input), the `Release` run's `macOS app` job calls `.github/workflows/desktop-dmg.yml` with the tag `codeman@<x.y.z>`. It has three jobs, so no job that holds a secret runs code from a crate: `build` runs `cargo test --locked` and `bundle-macos.sh --universal` on the release commit with a read-only token and no secret; `sign` imports the Developer ID certificate into a throwaway keychain and runs `packages/desktop/scripts/sign-macos.sh`, which signs the app with the hardened runtime, has Apple notarize it, staples the ticket, builds the DMG around it and signs, notarizes and staples that too; `attach` uploads both files to the release (`--clobber`) and checks that the download links serve those bytes. The release job's `Keep Latest on the Codeman release` step puts the Latest badge back on the newest `codeman@` release after every publish, since the stable link follows that badge.
**The secrets** are repository secrets, passed to the job by name (never `secrets: inherit`, which would hand it `NPM_TOKEN`): `MACOS_CERT_P12` (the Developer ID Application certificate WITH its private key, exported from Keychain Access as a .p12, then `base64 -i <file>.p12`), `MACOS_CERT_PASSWORD` (the export password), `APPLE_ID`, `APPLE_APP_PASSWORD` (an app-specific password for that Apple ID, made at account.apple.com) and `APPLE_TEAM_ID`. A Developer ID certificate lasts five years; its successor replaces the first two secrets and nothing else.
**Check it before anything links to it.** The DMG lands after a universal build and two notary submissions, so well after the npm publish, and until then the stable link answers 404:
```bash
gh release view codeman@<x.y.z> --json assets -q '.assets[].name' # Codeman.dmg and Codeman.dmg.sha256
curl -sIL -o /dev/null -w '%{http_code}\n' https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg # 200
```
**Dry run.** `gh workflow run desktop-dmg.yml` builds, signs and notarizes master and attaches nothing; `gh workflow run desktop-dmg.yml --ref <branch>` does the same for a branch that carries the workflow (GitHub starts a manual run only for a workflow file that is also on master). The signed DMG is the run's `codeman-dmg` artifact (`gh run download <run-id> -n codeman-dmg`). For a first-launch test, download it on a Mac through the browser from the run page instead, so the copy is quarantined like one from a release.
**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@<x.y.z>`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. The tag must be a Codeman release tag (`codeman@x.y.z`, or the `aicodeman@x.y.z` a failed rename leaves) whose commit is on master's history: `build` and `sign` both refuse anything else before they run its scripts, since `sign` runs them with the signing key unlocked. A failed job is also retried in place with `gh run rerun <run-id> --failed`.
**If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log <id>` with the same login shows it again).
- `HTTP status code: 401` (invalid credentials) or `403` (a required agreement is missing or has expired) at the first submission: a new Apple ID password revokes every app-specific password, so make a new one and update `APPLE_APP_PASSWORD`, or accept the new agreement at developer.apple.com, then `gh run rerun <run-id> --failed`.
- `0 valid Developer ID Application identities` in the `Signing keychain` step: the .p12 lacks its private key or holds another certificate type. A team mismatch there means `APPLE_TEAM_ID` names another team than the certificate.
- `In Progress` at the 45-minute limit, or a network error: Apple's service was slow. Rerun the failed job.
- `Invalid`: Apple's log names the file and the reason (a binary without the hardened runtime or a secure timestamp, an entitlement it refuses). That is a code fix and a new release. Until then keep the stable link on the last app that passed, `gh release edit codeman@<previous> --latest`, and hold any site change that links the new one.
**By hand on a Mac**, only when the workflow cannot be fixed in time: in a clean checkout of the tag, `env -u CODEMAN_DESKTOP_INSTALLER_URL -u CODEMAN_DESKTOP_INSTALL_BRANCH packages/desktop/scripts/bundle-macos.sh --universal`, then `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app <out-dir>` (a profile saved with `xcrun notarytool store-credentials`), then `gh release upload codeman@<x.y.z> <out-dir>/Codeman.dmg <out-dir>/Codeman.dmg.sha256 --clobber`. Never upload a build made with either of those two variables set: it installs Codeman from the test branch it names.
**Toolchain and runner.** `desktop-dmg.yml` and the pull-request check `desktop-ci.yml` (`cargo fmt --check`, clippy with `-D warnings` and `cargo test --locked` on macOS, for changes under `packages/desktop`) both pin `RUST_TOOLCHAIN` and run on `macos-26`. Move them together.
## Security notices
A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it.
+19
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 |
+1 -1
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
+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 |
| ---------------------- | ------------------------------------------------------------------------ |
+21 -5
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
@@ -124,6 +138,8 @@ from your side. See [Agent CLIs](Agent-CLIs) for the run modes.
Claude Code lead can hand work to it: Claude in the cloud plans and reviews, the local model
takes summaries, first drafts and files that should stay on the machine. It answers one
request at a time and is slower than a cloud model, and the skill never installs one for you.
You can name the coding agent the worker runs with ("a local worker with Pi"), as long as
that agent is installed; the walkthrough is in [Local Models](Local-Models#cloud-and-local-together).
With Claude Code workers, where the installed Claude Code supports it, the lead can also
message a worker directly instead of typing into its terminal.
+12 -1
View File
@@ -207,7 +207,9 @@ missing entry is the more current answer.
## A model that runs on this Mac
If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your
own: Codeman can set one model up for you on that Mac. This is experimental.
own: Codeman can set one model up for you on that Mac. This is experimental. The walkthrough,
with the coding agents and the cloud plus local setup, is on its own page:
[Local Models](Local-Models).
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
on this Mac).
@@ -234,6 +236,15 @@ Good to know:
but it is changed and removed in the local model window, not here.
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
starting next time.
- More than one model can be on the Mac, and one of them runs at a time. Once a model is
installed the window lists the models with what each takes on disk: **Add a model**
downloads another one, **Use** switches to a model that is already there (nothing is
downloaded; close the sessions on the model first), and **Delete** removes one, after one
question. The model in use cannot be deleted: switch first, or use **Remove**.
- The coding agent is chosen per session. The window lists every agent the model can be run
with (OpenCode, Claude Code, Pi, DeepSeek Harness) under **Start a session with**, and
installs one that is missing after showing you the command. DeepSeek Harness is set up
from the Run menu first.
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
coding agent stays installed.
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
@@ -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
```
+3 -1
View File
@@ -215,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.
+16 -6
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.
@@ -53,11 +53,21 @@ Only the Files panel moves. The **Git** panel stays on the session's folder, so
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 outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
only serves files inside the session's folder. The same goes for a session started in a subfolder
of the repository, because Claude Code puts the worktree at the top of the repository, which is
outside that session's folder. Open such a worktree as a case of its own with Add Case > Link
Existing.
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
+1
View File
@@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
| [Local Models](Local-Models) | How do I run a coding model on my own Mac, and use it next to a cloud model? |
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
+80 -2
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,10 +97,18 @@ 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.
@@ -216,6 +227,73 @@ image, the refreshed volumes and the entrypoint arrive together. The full guide,
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:
+115
View File
@@ -0,0 +1,115 @@
# Local Models
Run a coding model on your own Mac, next to the cloud models you already use. Codeman
installs the model, keeps it running, and lets you start sessions on it or hand it work from
a cloud session. No account and no API key: the model runs only on that Mac.
This is **experimental**, and it needs a Mac with Apple silicon as the machine the Codeman
server runs on. On a machine that is not a Mac the button stays hidden.
## Install a model
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
on this Mac).
2. The window shows which models fit this Mac. The bar at the top is the memory the Mac can
give a model, filled to what the selected model needs at its peak. A nearly full bar is a
tight fit.
3. Pick a model, and under **Runs with** the coding agent to start it with.
4. Press **Install on this Mac**. The next screen lists every download with its size and
where it goes. Nothing is downloaded before you confirm there.
5. Codeman downloads a model runtime and the model into `~/.codeman/local-model`, loads the
model, checks that all of it runs on the GPU and that it can call a tool, and installs
the coding agent if it is missing. No administrator password is asked for.
6. Press **Start a session**.
You can close the window while it downloads. **Cancel** pauses the install and **Continue**
picks it up where it stopped. After a Codeman restart an unfinished install waits for your
click.
## Pick the coding agent
A model is driven by a coding agent: the program that reads your prompt, calls tools and
edits files. You choose it **per session**. Open the window again and look at **Start a
session with**: one chip per agent the model can be run with.
| Agent | Pick it when |
| ---------------- | ---------------------------------------------------------------------------------------------- |
| OpenCode | You have no reason to choose. It is the pair Codeman was tested with on a Mac |
| Pi | You want the first answer soon. Its fixed prompt is the smallest by far, which also leaves the most room for your work |
| Claude Code | You want Claude Code's own tools and habits. Its first answer on a local model takes minutes |
| DeepSeek Harness | You already use it and have it set up |
Select a chip and press **Start a session**. The choice is remembered on that device.
**A chip that says "Not installed"** is an agent Codeman can install for you. Select it: the
window shows the exact command it would run, and the main button becomes **Install** with
the agent's name. The model stays ready while the installer runs, and you can start a
session with another agent meanwhile. If the install fails, the window says so under the
chip and nothing else changes.
**A chip that says "Needs setup"** is an agent that needs more than one command. DeepSeek
Harness is the one today: set it up from the **Run** menu first, then it can be started
here. A new DeepSeek Harness also opens its own setup guide in every session until you have
finished it once.
## More than one model
Once a model is installed, the window lists **Models on this Mac** with what each takes on
disk, and the total under the list.
- **Add a model** opens the list of models again and downloads another one. When it is
ready it becomes the model in use; the earlier one stays on the Mac.
- **Use** switches to a model that is already there. Nothing is downloaded: Codeman loads it
and checks it again.
- **Delete** removes one model, after one question that says how much space it frees.
One model runs at a time. Adding and switching are refused while sessions still use the
current model: close those sessions first. The model in use cannot be deleted: switch first,
or use **Remove**, which takes the runtime and the connection away (and the downloaded
models too, if you tick that).
## Cloud and local together
The reason to keep a local model next to a cloud one: the cloud model plans and reviews, the
Mac does the bulk. From a Claude Code session in Codeman, with the
[Codeman skill](Codeman-Skill) (on by default), you can just say it:
| You say | What happens |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "Is there a local model on this Mac?" | It reads what is installed, which agents can run it and which models are on the Mac. Read-only |
| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the local model does the reading; the cloud session reads its answer back and reviews it |
| "Spawn a local worker with Pi to draft tests for `src/parser.ts`." | The worker runs the local model through Pi |
| "Start two cloud workers for the API and the UI, and a local one for the changelog." | One mixed fleet: cloud workers side by side, the Mac as a queue |
The worker sessions appear as tabs in the dashboard and are closed when the work is done.
Good to know before you rely on it:
- **One request at a time.** The local model answers one request at a time, so two local
workers take turns. Fan out to the cloud, and give the Mac a queue.
- **Slower than a cloud model**, and the first request after a start loads the model first.
- **Review what comes back.** A local model is good at bulk work and weaker at judgment.
Treat its answer as a first draft.
- **The skill never installs anything.** A model or an agent that is missing is yours to
install in the window; the skill tells you what is missing and stops.
- **While a local worker is open**, the window refuses Stop, Remove and a switch of models,
and names the sessions that are in the way.
## Good to know
- **What was tested.** One pair was run on a real Mac: OpenCode with Qwen3.6 35B-A3B on an
M5 Pro with 48 GB. The window marks that pair on a Mac like it. Everything else in the
list is calculated from each model's own figures, and the install checks the model on your
Mac before it lets you start a session.
- **Memory first.** A model that does not fit in memory is not offered. An estimate becomes
a measurement the first time the runtime starts, and the model is judged against that
before it is downloaded.
- **The developer tools.** Coding agents use git and Python. On a Mac without them, macOS
offers to install them the first time an agent needs one.
- **Stop** keeps the runtime from starting with Codeman next time. **Start** undoes that.
- **In multi-user mode** only an admin sees and uses this.
More detail: [A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac)
for how it relates to your own model servers, and
[`docs/local-models.md`](https://github.com/Ark0N/Codeman/blob/master/docs/local-models.md)
for how it works inside.
+1 -1
View File
@@ -222,7 +222,7 @@ on. Details: [Custom Model Endpoints](Custom-Model-Endpoints#a-model-that-runs-o
| 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. |
+3 -1
View File
@@ -24,7 +24,7 @@ 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. 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. |
| 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. |
@@ -117,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
+1
View File
@@ -14,6 +14,7 @@
- [Tile Grid](Tile-Grid)
- [Agent CLIs](Agent-CLIs)
- [Custom Model Endpoints](Custom-Model-Endpoints)
- [Local Models](Local-Models)
- [Working With Files](Working-With-Files)
- [Git Worktrees](Git-Worktrees)
- [Input And Voice](Input-And-Voice)
+285 -46
View File
@@ -20,6 +20,10 @@
# --no-admin Never use sudo or Homebrew: download Node.js and tmux into
# the home folder instead (chosen by itself when this account
# has no administrator rights)
# --ai-cli <id>|none Which AI CLI to install when none is found, without the menu
# (the ids are the keys of the catalogue below)
# --progress Also print machine-readable progress lines (for the
# desktop app; see progress_phase)
# --help, -h Print this text
#
# Environment variables:
@@ -49,6 +53,8 @@
# CODEMAN_TARBALL_URL - Source archive (.tar.gz) to install from when no
# working git is available (default: the GitHub
# archive of CODEMAN_REPO_URL at CODEMAN_BRANCH)
# CODEMAN_AI_CLI - Same as --ai-cli
# CODEMAN_PROGRESS=1 - Same as --progress
#
# Subcommands:
# install.sh update - Update an existing install
@@ -57,6 +63,7 @@
# install.sh name [<n>] - Rename this machine on your tailnet (default: codeman-<hostname>)
# install.sh status - Print the URLs, the QR code and how to manage the service
# install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel
# install.sh preflight - Print what is on this machine as JSON; changes nothing
set -euo pipefail
@@ -165,6 +172,12 @@ ASSUME_YES="0"
# Set by parse_flags when a flag asks to change how an existing install is
# reached or run, so a bare re-run takes the full flow instead of a quiet update.
RECONFIGURE="0"
# --ai-cli / CODEMAN_AI_CLI: answers the AI CLI menu, with a catalogue id or
# "none". Empty means ask. Consent comes with the flag, so unlike the
# CODEMAN_NONINTERACTIVE default it installs exactly what was named.
AI_CLI_CHOICE="${CODEMAN_AI_CLI:-}"
# --progress / CODEMAN_PROGRESS=1: see progress_phase.
PROGRESS="${CODEMAN_PROGRESS:-0}"
SUBCOMMAND=""
SUBCOMMAND_ARG=""
# Answers to question 3 (choose_launch_mode): LAUNCH_CHOICE 1/2/3 as above,
@@ -272,6 +285,24 @@ warn() {
error() {
echo -e "${RED}Error:${NC} $1" >&2
if [[ "$PROGRESS" == "1" ]]; then
printf '@@codeman error %s\n' "${1//$'\n'/ }" >&2
fi
}
# progress_phase <name>: tell the desktop app which part of the install has
# begun. The protocol is one line, `@@codeman phase <name>`, for each of
# runtime, agent, source, packages, build, service and done, always in that
# order, and `@@codeman error <text>` for each error() above. A phase runs until
# the next one starts; a run that ends without `done` failed. A phase with
# nothing to do may be skipped. The app ignores a name it does not know, so a
# new phase needs no new app, but `done` must stay the last thing main() reports.
# On stderr, because this can be reached inside a $( ) that captures stdout.
progress_phase() {
if [[ "$PROGRESS" == "1" ]]; then
printf '@@codeman phase %s\n' "$1" >&2
fi
return 0
}
die() {
@@ -748,6 +779,17 @@ cli_catalog_names() {
printf '%s' "$out"
}
# "claude, opencode, codex, ...": the ids --ai-cli takes.
cli_catalog_ids() {
local i out=""
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
out="${out:+$out, }${CLI_IDS[$i]}"
done
printf '%s' "$out"
}
# The "install one yourself" hints: every enabled CLI that is not installed,
# showing the trusted install command. An entry with no install command gets
# its docs URL instead of being silently omitted, which is what used to
@@ -785,6 +827,129 @@ cli_catalog_print_install_hints() {
# the first time a hint is printed.
cli_catalog_select_platform
# cli_index <id>: print the catalogue index of an enabled CLI that has a binary
# to look for. Fails for anything else, `shell` included.
cli_index() {
local i
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
if [[ "${CLI_IDS[$i]}" == "$1" ]] && [[ "${CLI_ENABLED[$i]}" == "1" ]] && [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]]; then
printf '%s' "$i"
return 0
fi
done
return 1
}
# cli_offerable <index>: can this run install that entry by itself? 0 = yes,
# 1 = no, 2 = no, only because its command needs curl and this host has wget.
# One rule for the menu, for --ai-cli and for `preflight`, so what the desktop
# app lists is exactly what the menu would. detect_all_clis must have run.
#
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
# (vendor one-liners), others are `npm install -g …`, which never needed curl
# at all. A wget-only host used to lose the WHOLE menu over this, including
# every npm entry: the two literals this replaced went through
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
# silently stopped defaulting to Claude Code as documented. So this is decided
# per entry: only a command that actually starts with `curl ` is curl-dependent.
# Rewriting curl to wget inside a string about to be executed is the wrong
# instinct either way; the ones we can't run, we show as a hint.
cli_offerable() {
local i="$1"
[[ "${CLI_ENABLED[$i]}" == "1" ]] || return 1
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || return 1
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || return 1
[[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || return 1
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
return 2
fi
# Same reasoning for a Homebrew command: it cannot run where there is no
# Homebrew, and the no-admin route never installs one.
if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then
return 1
fi
return 0
}
# Tell a running Codeman to look again: it remembers a missing CLI for up to 5
# minutes (cliResolveRetryDelayMs), and its Run button reads that memory. The name
# is CLI_INSTALLED_MARKER in src/utils/cli-executable-resolver.ts. With no data
# folder there is no Codeman to tell, which is why a failure here is not one.
mark_cli_installed() {
touch "$HOME/.codeman/cli-installed" 2>/dev/null || true
return 0
}
# run_offered_cli <index>: run the trusted command of one entry and look again
# at what is installed. What that means is the caller's to judge.
run_offered_cli() {
local idx="$1"
info "Installing ${CLI_LABELS[$idx]}..."
# </dev/null: under `curl | bash` a child that reads stdin would
# consume the rest of this script.
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
hash -r 2>/dev/null || true
CLI_DETECT_DONE=""
detect_all_clis
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
mark_cli_installed
else
warn "${CLI_LABELS[$idx]} installation failed."
fi
return 0
}
# install_offered_cli <index>: the same during an install, where a chosen CLI
# that leaves no CLI at all behind is fatal.
install_offered_cli() {
run_offered_cli "$1"
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
return 0
}
# Install one AI CLI and nothing else (`install.sh agent <id>`): how the desktop
# app adds an agent once Codeman is here, also over a running server, which this
# never touches. It judges the run itself, because install_offered_cli cannot: its
# gate is fatal only when NO CLI is left, and here another one usually is, so a
# failed install would report success, and its message is about re-running the
# installer. A CLI that is here already is a success, and installs nothing.
agent_subcommand() {
if [[ -z "$AI_CLI_CHOICE" || "$AI_CLI_CHOICE" == "none" ]]; then
die "Name the AI CLI to install: install.sh agent <id> (one of: $(cli_catalog_ids))."
fi
check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first."
detect_all_clis
progress_phase agent
local idx offerable=0
idx=$(cli_index "$AI_CLI_CHOICE")
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} is already installed at ${CLI_FOUND_PATH[$idx]}"
mark_cli_installed
progress_phase done
return 0
fi
cli_offerable "$idx" || offerable=$?
if [[ "$offerable" -ne 0 ]]; then
die "${CLI_LABELS[$idx]} cannot be installed automatically here. See ${CLI_DOCS[$idx]:-its documentation}."
fi
# What this run sees is a login shell's PATH at best: a Node.js that only an
# interactive profile loads (nvm in .zshrc) is not on it.
if [[ "${CLI_INSTALL_CMD_TRUSTED[$idx]}" == npm\ * ]] && ! check_npm; then
die "${CLI_LABELS[$idx]} installs with npm, and this run cannot find it. Run this in a terminal instead: ${CLI_INSTALL_CMD_TRUSTED[$idx]}"
fi
run_offered_cli "$idx"
if [[ -z "${CLI_FOUND_PATH[$idx]}" ]]; then
die "${CLI_LABELS[$idx]} did not install. You can run its installer yourself: ${CLI_INSTALL_CMD_TRUSTED[$idx]}"
fi
progress_phase done
return 0
}
# Offer to install one AI CLI from the catalogue, or let the user skip.
#
# Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts
@@ -798,7 +963,10 @@ offer_ai_cli_install() {
local i
echo ""
warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)."
headless_guard "install an AI CLI (curl | bash from its vendor)"
# --ai-cli is the consent the guard otherwise looks for at a terminal.
if [[ -z "$AI_CLI_CHOICE" ]]; then
headless_guard "install an AI CLI (curl | bash from its vendor)"
fi
echo ""
# The menu is built from the catalogue: every enabled CLI that is not
@@ -810,36 +978,38 @@ offer_ai_cli_install() {
# ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED,
# the only array the generated block above writes and the only one the
# installer ever runs or displays — see cli_catalog_select_platform.
#
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
# (vendor one-liners), others are `npm install -g …`, which never needed
# curl at all. A wget-only host used to lose the WHOLE menu over this,
# including every npm entry — the two literals this replaced went through
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
# silently stopped defaulting to Claude Code as documented. Filter per
# entry instead: only a command that actually starts with `curl ` is
# curl-dependent, so only THOSE are held back on a wget-only host.
# Rewriting curl to wget inside a string about to be executed is the
# wrong instinct either way — the ones we can't run, we show as a hint.
local -a offer_idx=()
local curl_only_skipped=0
local curl_only_skipped=0 offerable
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue
[[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
curl_only_skipped=$((curl_only_skipped + 1))
continue
fi
# Same reasoning for a Homebrew command: it cannot run where there is no
# Homebrew, and the no-admin route never installs one.
if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then
continue
fi
offer_idx[${#offer_idx[@]}]=$i
offerable=0
cli_offerable "$i" || offerable=$?
case "$offerable" in
0) offer_idx[${#offer_idx[@]}]=$i ;;
2) curl_only_skipped=$((curl_only_skipped + 1)) ;;
esac
done
# The answer was given up front: never the menu, and never a different CLI
# than the one named. A name this host cannot install (parse_flags already
# refused a typo) warns and carries on, the same as Skip.
if [[ -n "$AI_CLI_CHOICE" ]]; then
local named="" entry
if [[ "$AI_CLI_CHOICE" != "none" ]]; then
named=$(cli_index "$AI_CLI_CHOICE") || named=""
for entry in ${offer_idx[@]+"${offer_idx[@]}"}; do
if [[ "$entry" == "$named" ]]; then
install_offered_cli "$named"
return 0
fi
done
warn "$AI_CLI_CHOICE cannot be installed automatically here. Codeman will run, but sessions need a CLI to drive."
else
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
fi
cli_catalog_print_install_hints
return 0
fi
if [[ "$curl_only_skipped" -gt 0 ]]; then
warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)."
fi
@@ -888,22 +1058,7 @@ offer_ai_cli_install() {
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
cli_catalog_print_install_hints
else
idx="${offer_idx[$((cli_choice - 1))]}"
info "Installing ${CLI_LABELS[$idx]}..."
# </dev/null: under `curl | bash` a child that reads stdin would
# consume the rest of this script.
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
hash -r 2>/dev/null || true
CLI_DETECT_DONE=""
detect_all_clis
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
else
warn "${CLI_LABELS[$idx]} installation failed."
fi
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
install_offered_cli "${offer_idx[$((cli_choice - 1))]}"
fi
fi
}
@@ -2033,7 +2188,8 @@ run_step() {
success "$label ($(fmt_elapsed $((SECONDS - start))))"
return 0
fi
error "$label failed (exit $rc). Last lines of $LOG_FILE:"
error "$label failed (exit $rc)."
echo "Last lines of $LOG_FILE:" >&2
tail -n 25 "$LOG_FILE" 2>/dev/null | sed 's/^/ /' >&2
return "$rc"
}
@@ -3723,6 +3879,7 @@ main() {
# ========================================================================
# Ask everything up front, so the build afterwards can run unattended
# ========================================================================
progress_phase runtime
ask_dependencies "$os" "$distro"
# AI CLI: needs a human choice AND node (installed just above), so it sits
@@ -3732,6 +3889,7 @@ main() {
CLI_DETECT_DONE=""
detect_all_clis
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
progress_phase agent
offer_ai_cli_install
fi
@@ -3746,8 +3904,11 @@ main() {
# ========================================================================
info "Installing Codeman to $INSTALL_DIR (a few minutes; you can leave this running)"
INSTALL_STARTED="1"
progress_phase source
install_or_update_repo
progress_phase packages
npm_install_deps
progress_phase build
run_step "Building Codeman" npm run build || exit 1
finish_source_swap
install_symlink
@@ -3758,6 +3919,7 @@ main() {
# (symlinks, PATH, launch menu) instead of silently "updating".
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
progress_phase service
local service_ok="true"
if [[ "$LAUNCH_CHOICE" == "2" ]]; then
if [[ "$SERVICE_TYPE" == "launchd" ]]; then
@@ -3782,6 +3944,7 @@ main() {
# Security notice: last informational block so it stays visible (when not
# auto-launching below; if we exec, the server prints the same notice anyway).
print_security_notice
progress_phase done
# Run now in foreground (must be last: exec replaces the shell)
if [[ "$LAUNCH_CHOICE" == "1" ]]; then
@@ -4520,6 +4683,65 @@ uninstall() {
echo ""
}
# json_string <text>: <text> as one JSON string. Control characters that JSON
# has no short form for are dropped; nothing printed here needs them.
json_string() {
local text
text=$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\010\013\014\016-\037')
text="${text//\\/\\\\}"
text="${text//\"/\\\"}"
text="${text//$'\t'/\\t}"
text="${text//$'\r'/\\r}"
text="${text//$'\n'/\\n}"
printf '"%s"' "$text"
}
json_bool() {
if [[ "$1" == "1" ]]; then printf 'true'; else printf 'false'; fi
}
# What is on this machine and what an install would do here, as one JSON object
# on stdout: the desktop app shows its install choices from this. Prompts for
# nothing, changes nothing, and never runs sudo beyond resolve_admin_mode's
# `sudo -n`. With --no-admin, `blocker` is why that route cannot finish here
# (empty when it can). `clis` lists the same entries as the AI CLI menu, with
# the same rule for `installable`, plus the ones already installed.
#
# `schema` is bumped only when a field changes meaning: the app is released on
# its own, and refuses a number it does not know.
preflight_subcommand() {
local os arch need_node="0" need_tmux="0" need_git="0" blocker="" i first="1"
os=$(detect_os)
arch=$(detect_arch)
check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first."
check_node || need_node="1"
check_tmux || need_tmux="1"
check_git || need_git="1"
detect_all_clis
if [[ "$NO_ADMIN" == "1" ]]; then
blocker=$(no_admin_blocker "$os" "$arch" "$need_node" "$need_tmux")
fi
printf '{"schema":1,"os":%s,"arch":%s,"noAdmin":%s,"blocker":%s' \
"$(json_string "$os")" "$(json_string "$arch")" "$(json_bool "$NO_ADMIN")" "$(json_string "$blocker")"
printf ',"needs":{"node":%s,"tmux":%s,"git":%s},"clis":[' \
"$(json_bool "$need_node")" "$(json_bool "$need_tmux")" "$(json_bool "$need_git")"
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
local installable="0" installed="0"
if cli_offerable "$i"; then installable="1"; fi
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || installed="1"
[[ "$first" == "1" ]] || printf ','
first="0"
printf '{"id":%s,"label":%s,"installed":%s,"installable":%s,"docs":%s}' \
"$(json_string "${CLI_IDS[$i]}")" "$(json_string "${CLI_LABELS[$i]}")" \
"$(json_bool "$installed")" "$(json_bool "$installable")" "$(json_string "${CLI_DOCS[$i]}")"
done
printf ']}\n'
return 0
}
usage() {
cat << 'EOF'
Codeman installer
@@ -4536,6 +4758,8 @@ Flags
--port <n> Port Codeman listens on (default 3000)
--no-admin Never use sudo or Homebrew: Node.js and tmux go into the
home folder (chosen by itself without administrator rights)
--ai-cli <id>|none Which AI CLI to install when none is found, without the menu
--progress Also print machine-readable progress lines
--help, -h This text
Subcommands
@@ -4545,11 +4769,13 @@ Subcommands
name [<n>] Rename this machine on your tailnet (default: codeman-<hostname>)
status Print the URLs, the QR code and how to manage the service
cloudflared Install cloudflared for the in-app Cloudflare tunnel
preflight Print what is on this machine as JSON; changes nothing
agent <id> Install one AI CLI and nothing else, also next to a running Codeman
Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST,
CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME,
CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH,
CODEMAN_NO_ADMIN=1 or 0, CODEMAN_TARBALL_URL.
CODEMAN_NO_ADMIN=1 or 0, CODEMAN_TARBALL_URL, CODEMAN_AI_CLI, CODEMAN_PROGRESS=1.
EOF
}
@@ -4577,6 +4803,12 @@ parse_flags() {
--no-start) LAUNCH_PRESET="3" ;;
--yes|-y) ASSUME_YES="1" ;;
--no-admin) NO_ADMIN="1" ;;
--ai-cli)
shift
[[ $# -gt 0 ]] || die "--ai-cli needs a value (a catalogue id, or none)"
AI_CLI_CHOICE="$1" ;;
--ai-cli=*) AI_CLI_CHOICE="${1#--ai-cli=}" ;;
--progress) PROGRESS="1" ;;
--password)
shift
[[ $# -gt 0 ]] || die "--password needs a value"
@@ -4588,13 +4820,15 @@ parse_flags() {
CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;;
--port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;;
--help|-h) usage; exit 0 ;;
update|uninstall|tailscale|name|status|cloudflared)
update|uninstall|tailscale|name|status|cloudflared|preflight|agent)
[[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
SUBCOMMAND="$1" ;;
-*) die "Unknown option: $1 (see --help)" ;;
*)
if [[ "$SUBCOMMAND" == "name" && -z "$SUBCOMMAND_ARG" ]]; then
SUBCOMMAND_ARG="$1"
elif [[ "$SUBCOMMAND" == "agent" && -z "$AI_CLI_CHOICE" ]]; then
AI_CLI_CHOICE="$1"
else
die "Unexpected argument: $1 (see --help)"
fi ;;
@@ -4604,6 +4838,9 @@ parse_flags() {
if [[ -n "${CODEMAN_PORT:-}" ]] && ! [[ "$CODEMAN_PORT" =~ ^[0-9]+$ && "$CODEMAN_PORT" -ge 1 && "$CODEMAN_PORT" -le 65535 ]]; then
die "Invalid port: $CODEMAN_PORT"
fi
if [[ -n "$AI_CLI_CHOICE" && "$AI_CLI_CHOICE" != "none" ]] && ! cli_index "$AI_CLI_CHOICE" >/dev/null; then
die "Unknown AI CLI: $AI_CLI_CHOICE (use none, or one of: $(cli_catalog_ids))"
fi
return 0
}
@@ -4628,6 +4865,8 @@ case "$SUBCOMMAND" in
name) setup_name_subcommand ;;
status) status_subcommand ;;
cloudflared) cloudflared_subcommand ;;
preflight) preflight_subcommand ;;
agent) agent_subcommand ;;
*)
# Only a COMPLETED install re-runs as a quiet update. A partial one
# (clone succeeded but build/menu never finished) lacks the marker and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.49.0",
"version": "1.50.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.49.0",
"version": "1.50.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.49.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",
+2
View File
@@ -0,0 +1,2 @@
target/
dist/
+3120
View File
File diff suppressed because it is too large. Load diff
+95
View File
@@ -0,0 +1,95 @@
[package]
name = "codeman-desktop"
version = "0.1.0"
edition = "2021"
rust-version = "1.90"
description = "Codeman Desktop: a small native window for a Codeman server"
license = "MIT"
publish = false
[[bin]]
name = "codeman-desktop"
path = "src/main.rs"
[dependencies]
# Window + event loop (tao), the system web view (wry: WKWebView on macOS,
# WebView2 on Windows, WebKitGTK on Linux) and native menus (muda). Tauri is
# built on these three; using them directly keeps the app small and gives us
# `load_url_with_headers`, which Tauri's navigate() does not have (see README).
tao = "0.37"
wry = { version = "0.57", features = ["devtools"] }
muda = "0.21"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
# The versions ureq and wry already pull in, so neither crate is built twice
# (ureq builds base64 without its default SIMD engines, and so do we).
base64 = { version = "0.23", default-features = false, features = ["std"] }
dirs = "7"
# The reachability probe. platform-verifier = the OS trust store, the same one
# WKWebView uses, so the probe and the window agree on which certificates pass.
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.
objc2 = { version = "0.6.5", default-features = false, features = ["std"] }
block2 = { version = "0.6.2", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = [
"std",
"objc2-core-foundation",
"block2",
"NSBundle",
"NSDate",
"NSDictionary",
"NSGeometry",
"NSObject",
"NSOperation",
"NSRange",
"NSSet",
"NSString",
"NSURL",
"NSURLAuthenticationChallenge",
"NSURLCredential",
"NSURLProtectionSpace",
"NSURLRequest",
"NSURLResponse",
"NSURLSession",
"NSValue",
] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
"std",
"objc2-core-foundation",
"NSAlert",
"NSApplication",
"NSButton",
"NSControl",
"NSResponder",
"NSTextField",
"NSView",
"NSWindow",
] }
objc2-web-kit = { version = "0.3.2", default-features = false, features = [
"std",
"block2",
"objc2-app-kit",
"WKFrameInfo",
"WKNavigationDelegate",
"WKSecurityOrigin",
"WKUIDelegate",
"WKWebView",
"WKWebsiteDataRecord",
"WKWebsiteDataStore",
] }
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true
panic = "abort"
+428
View File
@@ -0,0 +1,428 @@
# Codeman Desktop
A small native app that opens Codeman in its own window. Double-click it, and
it runs Codeman on this Mac (installing it first if it is not there) or
connects to Codeman on another machine (installing it there over SSH if
needed), and Codeman fills the window. `⌃⌘F` puts it in full screen.
macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
Windows and Linux later.
- **Size:** 5.3 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3.5 MB as a `.dmg` (measured 2026-11-09).
- **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 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
Codeman is already a web app, so the desktop app is a native window around the
operating system's own web view. An Electron build of the same thing would ship
a whole Chromium (around 150 MB). The native side only adds what a browser tab
cannot do.
## What it does
| | |
| --- | --- |
| **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 (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. |
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. Each pop-out opens a step down and to the right of the window in use, never exactly on top of another one. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
| **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)
Needs Rust 1.90 or newer ([rustup](https://rustup.rs); `rustup update` for an older
toolchain) and the Xcode Command Line Tools (`xcode-select --install`).
```bash
cd packages/desktop
cargo test # unit tests
./scripts/bundle-macos.sh # dist/Codeman.app for this Mac's architecture
./scripts/bundle-macos.sh --universal --dmg # Apple Silicon + Intel, plus dist/Codeman-<version>.dmg
open dist/Codeman.app
```
The connect page has its own test, which needs no Mac: from the repository
root, `npm run test:browser -- test/desktop-connect-page.browser.test.ts` drives
`assets/connect.html` in Chromium through every state of both tabs, under the
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
cargo config's `build.target-dir` work.
Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords
in the Keychain under *Codeman Desktop*, one item per username and server. A
`desktop.json` the app cannot read or parse is moved aside, never overwritten:
to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1`
to `.corrupt.9` when that one is taken, so an earlier copy is kept. If all ten
names are taken, or the file cannot be moved, the app starts with default
settings and saves nothing, which leaves the file where it is. When a server's
username changes (a change of letter case counts), or a server drops off the
recent list (it keeps 8), the Keychain item of the old username is deleted.
## Decisions, and why
**tao + wry + muda directly, not Tauri.** Tauri is built on the same three crates.
The deciding detail is login: Codeman's password protection is HTTP Basic, and
a WKWebView shows no login dialog of its own (wry 0.57 implements no
authentication-challenge handler). So the app sends the `Authorization` header
itself on the first load, with wry's `load_url_with_headers` (Tauri 2.12's
`navigate()` takes only a URL), and from then on answers the HTTP Basic
challenges of Codeman's own pages with the current login. Codeman keeps its
session cookies in memory, so after a server restart that answer is what keeps
the page logged in. Using the crates directly is also smaller.
**What wry leaves out is added at runtime, not patched in.** `src/macos.rs` adds
the JavaScript dialog methods and the authentication-challenge method to the
classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
**What a page may ask of the app** (all in `src/policy.rs`, unit-tested):
- Codeman shows content it did not write on its own origin: rendered HTML files
(`/html-view/`) and proxied dashboards (`/webview/`). Only Codeman's own app
shell (the dashboard and a session pop-out) may use `window.CodemanHost`.
That is an allowlist on the raw path, and a path holding any `%` fails it, so
an encoded spelling or a route Codeman adds later stays out.
- An app window opens only for Codeman's own pop-out pages under the server's
base path, checked after decoding the path once, and which ones depends on
who asks. `window.CodemanHost` (the app shell only) may open sessions,
rendered HTML views, the raw, preview, attachment and folder file routes, the
path picker's preview and `api/download`. A `window.open` or `target=_blank`
link, which WebKit passes on from any frame without saying which, gets only
the file routes and
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
HTML file could open copies of itself as top-level windows that pass for the
app's own. Such a window does not take keyboard focus, and these windows
have a budget of their own (3 at once, then 1 per second), apart from the
one links draw on. Any other page on the server's origin is refused rather
than sent to the browser: a top-level `/webview/` load would escape the web
tab's iframe sandbox in either place, so it never opens anywhere.
`window.open('')` is refused too, since WebKit's blank window carries none of
the app's handlers.
- wry's navigation handler sees every frame and does not say which one. So a
navigation to another site is blocked rather than opened in the browser;
otherwise a page full of external iframes could open browser tabs. A real
link click is sent out by the injected script in the main frame only.
- Only `http(s)` URLs ever reach the default browser, at most 3 at once and
then 1 per second. macOS lets a page call `window.open` without a click, so
this keeps a script from opening a browser tab for every call.
- The `Authorization` header is only sent to URLs under the server's base path.
The login answers a Basic challenge only when it is Codeman's own: the
server's scheme, host and port (never a proxy's), a URL below the base path,
and not a web tab (`webview/`) or a rendered HTML file (`html-view/`), which
relay 401s from content Codeman did not write. Only such a challenge can
send the user back to the connect screen. WebKit is handed the login without
persistence, so it keeps no copy: every challenge gets the current login,
and one from before a Switch Server is never replayed.
- Each connect starts by clearing the app's cookies (never local storage).
Codeman checks its session cookie before the `Authorization` header, so an
earlier user's cookie would otherwise win over the new login.
- Microphone and camera requests prompt per page. wry's default without a
handler is to grant them to every frame, sandboxed previews included.
- The bundled connect page keeps working until a server page commits in the
main window (it is still the live page while the server loads), and only
the main window may talk to it.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**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
`~/.codeman/.env` (what `codeman attach`, `codeman tui` and `codeman agent`
read). The app reads the same places, so a password-protected Codeman on this
Mac opens without typing the password. The username and password always come
from the same place, since the server reads its username from its own
environment. The login goes only to `127.0.0.1` on that Codeman's port, and
only while `lsof` shows one of your own Codeman processes holding that port:
with Codeman stopped, whatever else listens there never gets it.
**Never a second server on the same sessions.** Two Codeman servers on one
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 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.
**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. 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 restart, each request that arrives
without a cookie gets a 401 before the
login goes out, and those count until one succeeds. Many windows reconnecting
at once can push a request past 10, and that request then fails with a 429
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
machine, serve Codeman through `tailscale serve`
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse
proxy with a real certificate. Marking the certificate as trusted in
Keychain Access can only help at `https://localhost` or `https://127.0.0.1`,
the only names Codeman's certificate carries, and may not work even there.
The connect screen says so when it happens.
- **Keychain prompt after every rebuild of an ad-hoc build.** The Keychain
knows the app that saved a password by its code signature, and an ad-hoc
signature changes with every build and update. So each new build asks once
for access to the saved passwords: choose *Always Allow*. All Keychain work
runs on one background thread, so the prompt does not freeze the window
while it waits for an answer. To keep one identity across builds, sign with
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
is enough). Microphone and camera permissions are asked again for the same
reason.
- **Gatekeeper, for a build of your own.** The `Codeman.dmg` on each GitHub
release is signed with a Developer ID and notarized by CI
(`.github/workflows/desktop-dmg.yml`), so it opens like any downloaded app.
A build without a Developer ID is signed ad hoc: it opens normally on the Mac
that built it, while a copy that arrived by download or AirDrop is
quarantined. On macOS 15 and later, try to open it once, then click *Open
Anyway* in System Settings > Privacy & Security (it asks for an administrator
password). On macOS 14 and earlier, right-click it and choose *Open* once.
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
copy in Applications works too. To sign and notarize by hand:
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
where `scripts/sign-macos.sh` lists what it needs.
- **Local Network permission (macOS 15 and later).** The first connection to an
address on the 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 it was denied,
turn Codeman on in System Settings > Privacy & Security > Local Network. A
server started with *Start Codeman on this Mac* counts as part of the app
here, so the same answer covers what it reaches on the local network (ssh to
LAN hosts, Wake-on-LAN, web tabs pointing at LAN dashboards), also after the
app quits.
- **Subagent transcript windows do not open in the app.** Codeman writes that
view into a blank `window.open('')` window, which the app refuses (see
Decisions), and shows a toast instead. A Codeman without the null check in
`viewSubagentTranscript` (`src/web/public/panels-ui.js`) reports *Failed to
load transcript* there. Open the transcript from a browser tab.
- **Dialogs are app-modal.** While a JavaScript dialog is open, every Codeman
window waits for it. A page that calls `alert()` in a loop (a rendered HTML
preview or a web tab can) leaves no way out but Force Quit (`⌥⌘⎋`).
- **Codeman needs the WebKit of Safari 16.4.** The app itself runs on macOS
11.3 or later (downloads go through WKDownload, which arrived in 11.3), but
Codeman's terminal (xterm.js 6 uses class static blocks) needs the WebKit
that came with Safari 16.4: macOS 13.3, or macOS 11 or 12 with Safari 16.4
or later installed. On an older WebKit the connect screen says so and does
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
which the same requirement covers.
- **Plain links in rendered HTML and web tabs do not open.** A link to another
site inside a rendered HTML preview or a web tab does nothing, in the
preview overlay, and for a rendered HTML file in its pop-out window too: the
app blocks navigation to other sites in every frame (see Decisions). A link
there that asks for a new window (`target="_blank"`, or a script's
`window.open`) does open, in your default browser, within the budget of 3 at
once and then 1 per second.
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
only Codeman's own login, and WebKit shows no dialog for any other, so a
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
- **Downloads from previews and web tabs are saved without asking.** Rendered
HTML files and web tabs are allowed to start downloads, and the app saves
every download to `~/Downloads` without a prompt. Asking first for those
frames is not done yet.
- **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
prints the address, and you connect to it under Another machine.
- **A This Mac install always sets Codeman up as a LaunchAgent** that starts
at login. To stop it: `launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist`;
to remove Codeman: `curl -fsSL https://getcodeman.com/install | bash -s -- uninstall`.
- **Windows and Linux** need their menu hookup (`init_for_hwnd`, GTK), a
credential store (Credential Manager, Secret Service) and installers. The
rest of the code is platform-neutral.
## Ideas for next
- Native notifications and a Dock badge with the number of sessions waiting
for you (tao has `set_badge_label`; Codeman already tracks which sessions need input).
- A global hotkey that brings Codeman to the front from anywhere.
- A menu bar icon showing session states.
- Trusting a self-signed server certificate by its fingerprint.
- Asking before a download that a rendered HTML file or a web tab starts.
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
- Windows and Linux builds, auto-update.
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Codeman</string>
<key>CFBundleDisplayName</key>
<string>Codeman</string>
<key>CFBundleIdentifier</key>
<string>com.getcodeman.desktop</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleExecutable</key>
<string>codeman-desktop</string>
<key>CFBundleIconFile</key>
<string>Codeman</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<!-- 11.3 is where WKDownload arrived; wry saves files through it. Below
that a Download link replaces the window or saves nothing.
scripts/bundle-macos.sh builds for this version too. -->
<key>LSMinimumSystemVersion</key>
<string>11.3</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>MIT License, Codeman Contributors</string>
<!-- Voice dictation and gesture control ask for these; without the
strings macOS kills the app the first time a page requests them. -->
<key>NSMicrophoneUsageDescription</key>
<string>Codeman uses the microphone for voice dictation when you turn it on.</string>
<key>NSCameraUsageDescription</key>
<string>Codeman uses the camera for hand-gesture control when you turn it on.</string>
<!-- macOS 15 asks before an app reaches the local network (a LAN address,
a .local name), and shows this text in that question. A process the
app starts counts as the app (Apple TN3179), so a server started with
"Start Codeman on this Mac" runs under the same answer. -->
<key>NSLocalNetworkUsageDescription</key>
<string>Codeman connects to Codeman servers on your local network. A Codeman server you start from this app uses the same permission for what it reaches on the network.</string>
<!-- App Transport Security would block plain http:// to a host NAME
(my-mac.local, a LAN box). IP literals are exempt, which is why
http://127.0.0.1 works without this. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
</dict>
</plist>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Only used with a Developer ID (SIGN_IDENTITY=...), which turns on the
hardened runtime; there, microphone and camera need these. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
max_width = 120
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Build Codeman.app (and optionally a .dmg) from packages/desktop, on macOS.
#
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> \
# packages/desktop/scripts/bundle-macos.sh --universal --notarize
#
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
# and later, try to open it once, then click Open Anyway in System Settings >
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
# in /Applications. A Developer ID signature + notarization avoids all of this.
#
# --dmg writes dist/Codeman-<version>.dmg, signed with SIGN_IDENTITY when it is
# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it
# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does
# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied).
# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE
# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow
# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and
# sign-macos.sh in another, so the job that runs cargo holds no secret.
set -euo pipefail
cd "$(dirname "$0")/.."
[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; }
UNIVERSAL=0
DMG=0
NOTARIZE=0
for arg in "$@"; do
case "$arg" in
--universal) UNIVERSAL=1 ;;
--dmg) DMG=1 ;;
--notarize) NOTARIZE=1 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
if [ "$NOTARIZE" = 1 ]; then
# Checked before the build, so a missing secret costs seconds, not a build.
./scripts/sign-macos.sh --check
fi
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
# The binary targets the same macOS as LSMinimumSystemVersion in Info.plist,
# so the two cannot disagree.
MACOSX_DEPLOYMENT_TARGET=$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' macos/Info.plist)
[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "could not read LSMinimumSystemVersion from macos/Info.plist" >&2; exit 1; }
export MACOSX_DEPLOYMENT_TARGET
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Build with cargo and set BUILT to the executable cargo reports for
# codeman-desktop. Asking cargo, rather than assuming ./target, keeps
# CARGO_TARGET_DIR, build.target-dir and build.target from a cargo config
# working, and a stale binary from an older build is never bundled.
cargo_build() {
local json
json=$(cargo build --locked --release "$@" --message-format=json-render-diagnostics)
BUILT=$(printf '%s\n' "$json" | awk '
index($0, "\"reason\":\"compiler-artifact\"") &&
index($0, "\"kind\":[\"bin\"]") &&
index($0, "\"name\":\"codeman-desktop\"") &&
match($0, /"executable":"[^"]*"/) { path = substr($0, RSTART + 14, RLENGTH - 15) }
END { print path }')
if [ -z "$BUILT" ] || [ ! -x "$BUILT" ]; then
echo "cargo did not report a codeman-desktop executable${BUILT:+ (it named $BUILT)}" >&2
exit 1
fi
}
if [ "$UNIVERSAL" = 1 ]; then
rustup target add aarch64-apple-darwin x86_64-apple-darwin >/dev/null
cargo_build --target aarch64-apple-darwin
ARM_BIN=$BUILT
cargo_build --target x86_64-apple-darwin
INTEL_BIN=$BUILT
BIN="$WORK/codeman-desktop-universal"
lipo -create -output "$BIN" "$ARM_BIN" "$INTEL_BIN"
else
cargo_build
BIN=$BUILT
fi
APP=dist/Codeman.app
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/codeman-desktop"
sed "s/__VERSION__/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist"
plutil -lint "$APP/Contents/Info.plist" >/dev/null
printf 'APPL????' > "$APP/Contents/PkgInfo"
# assets/icon.png (1024x1024, already on Apple's icon grid) -> Codeman.icns
ICONSET="$WORK/Codeman.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" assets/icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
double=$((size * 2))
sips -z "$double" "$double" assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/Codeman.icns"
if [ -n "${SIGN_IDENTITY:-}" ]; then
codesign --force --options runtime --timestamp \
--entitlements macos/entitlements.plist --sign "$SIGN_IDENTITY" "$APP"
else
codesign --force --sign - "$APP"
fi
codesign --verify --strict "$APP"
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
if [ "$NOTARIZE" = 1 ]; then
./scripts/sign-macos.sh "$APP" dist
elif [ "$DMG" = 1 ]; then
DMG_PATH="dist/Codeman-$VERSION.dmg"
./scripts/make-dmg.sh "$APP" "$DMG_PATH"
if [ -n "${SIGN_IDENTITY:-}" ]; then
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH"
fi
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
fi
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Put Codeman.app in a compressed disk image next to a link to /Applications,
# so opening the image shows the app and the folder to drag it into.
#
# packages/desktop/scripts/make-dmg.sh <Codeman.app> <out.dmg>
#
# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG
# and the release DMG are laid out the same way. It signs nothing.
set -euo pipefail
[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; }
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out.dmg>" >&2; exit 2; }
APP=$1
DMG=$2
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
STAGE="$WORK/dmg"
mkdir -p "$STAGE"
# ditto keeps what cp -R can lose: extended attributes, and with them a
# stapled notarization ticket.
ditto "$APP" "$STAGE/$(basename "$APP")"
ln -s /Applications "$STAGE/Applications"
# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the
# same command a few seconds later passes.
rm -f "$DMG"
tries=1
until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do
if [ "$tries" -ge 3 ]; then
echo "hdiutil create failed $tries times" >&2
exit 1
fi
tries=$((tries + 1))
echo "hdiutil create failed, trying again in 10 seconds" >&2
rm -f "$DMG"
sleep 10
done
hdiutil verify -quiet "$DMG"
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the
# ticket to it, then build Codeman.dmg around the stapled app and do the same
# for the DMG. On macOS.
#
# packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>
# packages/desktop/scripts/sign-macos.sh --check # only check the environment below
#
# <Codeman.app> is re-signed and stapled in place. <out-dir> receives
# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it,
# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both
# appear only once every check passed; a failed run leaves neither, not even an
# older pair, so nothing half-done can be uploaded by mistake.
#
# The environment names who signs:
# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain
# on the search list, by name or by the SHA-1 hash that
# `security find-identity -v -p codesigning` prints
# and how to reach Apple's notary service, either through a profile saved with
# `xcrun notarytool store-credentials <profile> --apple-id ... --team-id ...`:
# NOTARY_PROFILE the profile's name
# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login)
# or with the Apple ID itself:
# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID
#
# By hand, on a Mac with the certificate in the login keychain:
# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \
# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist
#
# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that
# builds nothing: the job that runs cargo, and with it the build script of every
# crate in Cargo.lock, never holds a signing secret.
set -euo pipefail
[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; }
DESKTOP=$(cd "$(dirname "$0")/.." && pwd)
ENTITLEMENTS="$DESKTOP/macos/entitlements.plist"
# Fails with the name of what is missing, before any work, so a missing secret
# costs seconds rather than a build.
check_env() {
if [ -z "${SIGN_IDENTITY:-}" ]; then
echo "sign-macos.sh needs SIGN_IDENTITY" >&2
exit 2
fi
if [ -n "${NOTARY_PROFILE:-}" ]; then
return 0
fi
local name
for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then
echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2
exit 2
fi
done
}
if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then
check_env
exit 0
fi
[ $# -eq 2 ] || { echo "usage: $0 <Codeman.app> <out-dir> | --check" >&2; exit 2; }
check_env
APP=$1
OUT=$2
[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; }
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256"
NAME=$(basename "$APP" .app)
if [ -n "${NOTARY_PROFILE:-}" ]; then
NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE")
if [ -n "${NOTARY_KEYCHAIN:-}" ]; then
NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN")
fi
else
# On notarytool's command line, which the job's log never shows.
NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID")
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Submit a file to the notary service and wait. Anything but Accepted stops the
# script with the submission id and Apple's log, which names the file and the
# reason (an unsigned binary, a missing timestamp, no hardened runtime...).
notarize() {
local file=$1 result="$WORK/notary.plist" rc=0 status id
echo "Submitting $(basename "$file") to Apple's notary service"
xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \
--output-format plist > "$result" || rc=$?
status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true)
id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true)
if [ "$status" != Accepted ]; then
echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2
cat "$result" >&2 || true
if [ -n "$id" ]; then
xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true
fi
exit 1
fi
echo "Notarized $(basename "$file") (submission $id)"
}
# The app: hardened runtime (which notarization requires), a secure timestamp,
# and the entitlements the hardened runtime needs for microphone and camera.
codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" "$APP"
codesign --verify --strict --verbose=2 "$APP"
DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1)
printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true
case "$DETAILS" in
*"Authority=Developer ID Application: "*) ;;
*) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;;
esac
case "$DETAILS" in
*"(runtime)"*) ;;
*) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;;
esac
ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip"
notarize "$WORK/$NAME.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl --assess --type execute --verbose=2 "$APP"
# The DMG around the stapled app, signed and notarized in its own right, so the
# image opens without a warning and the app inside opens offline.
DMG="$WORK/Codeman.dmg"
"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG"
codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG"
(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256)
mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/"
echo "Signed and notarized $APP"
echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")"
+465
View File
@@ -0,0 +1,465 @@
//! 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)
}
/// A refused login: Codeman counts each against the login rate limit, so the
/// polling ends.
fn refused(status: u16) -> bool {
matches!(status, 401 | 403 | 429)
}
/// What a status code says about asking again.
fn answer(status: u16, body: Option<&str>) -> Poll {
match status {
200 => body.and_then(count_working).map_or(Poll::Failed, |working| {
Poll::Seen(Activity {
working: working > 0,
..Activity::default()
})
}),
s if refused(s) => 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.as_deref()) {
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((s, _)) if refused(s) => 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>")), 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);
}
}
+316
View File
@@ -0,0 +1,316 @@
//! Files bundled into the binary: the connect page, its icon, and the script
//! injected into every Codeman page (`window.CodemanHost`).
use std::borrow::Cow;
use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode};
const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html");
const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
/// The connect page loads nothing from outside the app.
const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src codemanapp:";
/// Serve `codemanapp://localhost/<path>`.
pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
let (body, mime): (&'static [u8], &str) = match request.uri().path() {
"/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"),
"/icon.png" => (ICON_PNG, "image/png"),
_ => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Cow::Borrowed(&b"not found"[..]))
.expect("static response");
}
};
Response::builder()
.header(CONTENT_TYPE, mime)
.header("Content-Security-Policy", CSP)
.header("Cache-Control", "no-store")
.body(Cow::Borrowed(body))
.expect("static response")
}
/// Injected at document start into the MAIN frame of every page (wry's
/// default), where it does nothing unless the page is http(s). It provides the
/// native-wrapper bridge Codeman's frontend already looks for
/// (`window.CodemanHost.openWindow/closeWindow/focusWindow`, see
/// `openInHostWindow` in src/web/public/app.js), so pop-outs become real app
/// windows. `openWindow` answers false, posting nothing, for a URL the native
/// side would drop (not http(s), or longer than its 4096-byte cap), so
/// Codeman shows its own error instead of marking a session detached with no
/// window behind it. Besides that it:
///
/// - turns a click on a same-origin download link (`download=true`, or a path
/// ending in `/download`) into a real download by giving the link a
/// `download` attribute. Codeman's file tree and attachment cards link to
/// `file-raw?download=true` without one, and the server sends a type WebKit
/// can show (png, pdf, txt), so WebKit would otherwise replace the Codeman
/// window with the file, with no way back. This runs in the capture phase,
/// before the file tree's own `stopPropagation`.
/// - does the same for `window.open` of such a `download=true` URL (the
/// attachment history), returning null instead of a window.
/// - sends clicks on links to other sites to the default browser, after
/// Codeman's own handlers have had their turn (bubble phase, skipped when
/// one of them called preventDefault). Middle clicks and modifier clicks
/// count too: a bare WKWebView has no new-tab gesture of its own, so they
/// would do nothing.
/// - reports the first same-origin 401 a `fetch` gets (`sessionExpired`),
/// once per page load: the session cookie is gone, usually because the
/// server restarted, and nothing in the page asks for the login again.
/// - defines `__codemanDesktopRefused(id)` (not enumerable) for
/// [`refused_script`].
pub const HOST_SHIM: &str = r#"(function () {
'use strict';
if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return;
var MAX_URL = 4096;
var post = function (msg) {
try { window.ipc.postMessage(JSON.stringify(msg)); return true; } catch (e) { return false; }
};
// An http(s) URL object, or null. fetch and window.open resolve against
// the document base (Codeman sets <base href>), so those pass it.
var httpUrl = function (raw, base) {
try {
var u = new URL(String(raw), base || location.href);
return /^https?:$/.test(u.protocol) ? u : null;
} catch (e) { return null; }
};
var sameOrigin = function (u) { return !!u && u.origin === location.origin; };
var link = function (ev) {
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
return a && typeof a.href === 'string' && !a.hasAttribute('download') ? a : null;
};
Object.defineProperty(window, 'CodemanHost', {
value: Object.freeze({
desktop: true,
openWindow: function (url) {
var u = httpUrl(url);
return u && u.href.length <= MAX_URL ? post({ cmd: 'openWindow', url: u.href }) : false;
},
closeWindow: function () { post({ cmd: 'closeWindow' }); },
focusWindow: function () { post({ cmd: 'focusWindow' }); }
})
});
// The native side could not open a window: say so. `id` is a string only
// when CodemanHost.openWindow asked for that session's pop-out; that session
// goes back to the dashboard (a fresh channel object, so this window's own
// channel hears it too). Anything else, null included, only shows the toast.
Object.defineProperty(window, '__codemanDesktopRefused', {
value: function (id) {
try {
if (window.app && typeof window.app.showToast === 'function') {
window.app.showToast('Could not open a new window', 'error');
}
} catch (e) {}
try {
if (typeof id === 'string' && id !== '' && typeof BroadcastChannel === 'function') {
var ch = new BroadcastChannel('codeman-windows');
ch.postMessage({ type: 'redocked', id: id });
setTimeout(function () { try { ch.close(); } catch (e) {} }, 2000);
}
} catch (e) {}
}
});
var markDownload = function (ev) {
if (ev.button > 1) return;
var a = link(ev);
var u = a && httpUrl(a.href);
if (sameOrigin(u) && (u.searchParams.get('download') === 'true' || /\/download$/.test(u.pathname))) {
a.setAttribute('download', '');
}
};
window.addEventListener('click', markDownload, true);
window.addEventListener('auxclick', markDownload, true);
var lastMiddle = null;
var openOutside = function (ev) {
if (ev.defaultPrevented || ev.button > 1) return;
var a = link(ev);
var u = a && httpUrl(a.href);
if (!u || sameOrigin(u)) return;
ev.preventDefault();
// Older WebKit sends a middle click as 'click', newer as 'auxclick'; one
// that sent both must still open the link once.
if (ev.button === 1) {
if (lastMiddle && lastMiddle.a === a && ev.timeStamp - lastMiddle.t < 500) return;
lastMiddle = { a: a, t: ev.timeStamp };
}
post({ cmd: 'openExternal', url: u.href });
};
window.addEventListener('click', openOutside, false);
window.addEventListener('auxclick', openOutside, false);
var nativeOpen = window.open;
if (typeof nativeOpen === 'function') {
window.open = function open(url) {
try {
var u = arguments.length && url !== undefined ? httpUrl(url, document.baseURI) : null;
if (sameOrigin(u) && u.searchParams.get('download') === 'true') {
var a = document.createElement('a');
a.href = u.href;
a.setAttribute('download', '');
a.click();
return null;
}
} catch (e) {}
return nativeOpen.apply(this, arguments);
};
}
// Returns the promise fetch itself returned, so callers see exactly what
// they saw before; the 401 check hangs off a side branch that swallows its
// own errors and never reads the body.
var expired = false;
var nativeFetch = window.fetch;
if (typeof nativeFetch === 'function') {
var check = function (input, r) {
if (expired || !r || r.status !== 401) return;
var raw = r.url || (input && typeof input === 'object' && typeof input.url === 'string' ? input.url : input);
if (!sameOrigin(httpUrl(raw, document.baseURI))) return;
expired = true;
post({ cmd: 'sessionExpired' });
};
window.fetch = function fetch(input) {
var p = nativeFetch.apply(this, arguments);
try {
if (!expired && p && typeof p.then === 'function') {
p.then(function (r) { try { check(input, r); } catch (e) {} }, function () {});
}
} catch (e) {}
return p;
};
}
})();"#;
/// 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")
};
toast_script(&text, if ok { "success" } else { "error" })
}
/// Runs in the window that asked for a window the native side did not open
/// (pop-out limit reached, policy refused it, window or web view failed to
/// build): the page shows an error toast. Does nothing on a page without the
/// shim.
///
/// `session_id` is the decoded id of a session pop-out
/// (`policy::session_id_of`), and only for a `CodemanHost.openWindow` request,
/// which comes from a trusted frame: that session then gets its tab back in
/// the dashboard. Pass `None` for every other refusal, above all the ones a
/// `window.open` or `target=_blank` from any frame caused, so a page cannot
/// make the dashboard drop the detached state of a session that is still
/// popped out.
pub fn refused_script(session_id: Option<&str>) -> String {
let id = serde_json::to_string(&session_id).unwrap_or_else(|_| "null".into());
format!(
"try {{ if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused({id}); }} catch (e) {{}}"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn get(path: &str) -> Response<Cow<'static, [u8]>> {
respond(
Request::builder()
.uri(format!("codemanapp://localhost{path}"))
.body(Vec::new())
.unwrap(),
)
}
#[test]
fn serves_page_and_icon_only() {
let page = get("/index.html");
assert_eq!(page.status(), StatusCode::OK);
assert!(page.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("text/html"));
assert!(page.headers().contains_key("content-security-policy"));
assert!(String::from_utf8_lossy(page.body()).contains("window.__codemanDesktop"));
assert_eq!(get("/icon.png").status(), StatusCode::OK);
assert_eq!(get("/../etc/passwd").status(), StatusCode::NOT_FOUND);
assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND);
}
#[test]
fn refused_script_escapes_the_session_id() {
let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\"));
assert!(js.starts_with("try { if (typeof window.__codemanDesktopRefused === 'function') "));
assert!(js.contains(r#"window.__codemanDesktopRefused("a'\"); alert(1); //"#));
assert!(js.contains("\u{2028}\\\\\"); } catch (e) {}"));
assert!(js.ends_with(" } catch (e) {}"));
}
#[test]
fn refused_script_without_a_session_passes_null() {
assert_eq!(
refused_script(None),
"try { if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused(null); } catch (e) {}"
);
assert!(refused_script(Some("")).contains("__codemanDesktopRefused(\"\")"));
}
#[test]
fn shim_keeps_its_contract() {
for needle in [
"Object.freeze",
"openWindow",
"closeWindow",
"focusWindow",
"__codemanDesktopRefused",
"sessionExpired",
"'auxclick'",
"MAX_URL = 4096",
"typeof id === 'string' && id !== ''",
] {
assert!(HOST_SHIM.contains(needle), "{needle}");
}
// The refused hook takes the session id the native side resolved; it
// never parses or decodes a URL to find one.
assert!(!HOST_SHIM.contains("decodeURIComponent"));
}
/// The connect page refuses what the app would drop without an answer, so
/// its two limits have to be the app's own.
#[test]
fn connect_page_keeps_the_message_limits() {
let page = String::from_utf8_lossy(CONNECT_HTML);
for needle in [
format!("var MAX_URL = {};", crate::policy::MAX_URL_LEN),
format!("var MAX_FIELD = {};", crate::policy::MAX_FIELD_LEN),
] {
assert!(page.contains(&needle), "{needle}");
}
}
/// Copy puts setup::INSTALL_ONE_LINER on the clipboard, and the page shows
/// the command as text of its own: the two have to be the same command.
#[test]
fn connect_page_shows_the_command_that_copy_copies() {
let page = String::from_utf8_lossy(CONNECT_HTML);
let shown = format!("<code id=\"oneliner\">{}</code>", crate::setup::INSTALL_ONE_LINER);
assert!(page.contains(&shown), "{shown}");
}
#[test]
fn toast_escapes_file_names() {
let js = download_toast_script("a\"); alert(1); (\".txt", true);
assert!(js.contains(r#"Downloaded a\"); alert(1); (\".txt to Downloads"#));
}
}
+859
View File
@@ -0,0 +1,859 @@
//! The app's own settings: recent servers, two preferences and the window
//! frame. Stored as JSON next to other per-app data
//! (`~/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;
use std::path::{Path, PathBuf};
const MAX_SERVERS: usize = 8;
/// How many recovery copies of an unreadable config can sit side by side:
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
const ASIDE_SLOTS: usize = 10;
pub const MIN_WIDTH: f64 = 480.0;
pub const MIN_HEIGHT: f64 = 360.0;
const MAX_EDGE: f64 = 20_000.0;
/// Height of a standard title bar, in points.
const TITLE_BAR: f64 = 28.0;
/// How far the outer top of the window lies above `WindowFrame::y`. On macOS
/// `y` is the content area's top (tao's `with_position` puts the content rect
/// there and the title bar above it); elsewhere `y` is already the outer top.
const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 };
/// How much of the title bar must be on a screen to grab it.
const MIN_GRIP: f64 = 80.0;
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
/// bounds, so the menu bar and the Dock are inside them.
const FIT_MARGIN_X: f64 = 40.0;
const FIT_MARGIN_Y: f64 = 80.0;
/// Room a first-run window leaves above its content (the menu bar, taller on
/// a display with a notch, and its own title bar) and below it (a Dock of the
/// default size): `Rect` is the whole display, so neither is taken off yet.
const FIRST_RUN_ROOM_ABOVE: f64 = 66.0;
const FIRST_RUN_ROOM_BELOW: f64 = 90.0;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct Config {
/// Most recent first.
pub servers: Vec<ServerEntry>,
/// Connect to the most recent server at launch.
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")]
pub window: Option<WindowFrame>,
}
fn lenient_window<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowFrame>, D::Error> {
let value = serde_json::Value::deserialize(d)?;
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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ServerEntry {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default)]
pub has_saved_password: bool,
}
/// The main window's position and inner size, in logical (point)
/// coordinates. `x`/`y` are where tao's `with_position` wants them: on macOS
/// the top-left of the CONTENT area (the title bar, about 28 pt, sits above
/// it), elsewhere the window's outer top-left. Saving the outer corner on
/// macOS and restoring it as the content corner made the window creep up by
/// a title bar on every launch.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WindowFrame {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
#[serde(default)]
pub fullscreen: bool,
}
/// A display's full bounds in logical coordinates, as tao reports them. This
/// is the whole display, not the work area: the menu bar and the Dock are
/// inside it.
#[derive(Debug, Clone, Copy)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl WindowFrame {
/// The frame of a first-run window on `screen`: `size` or what fits, never
/// less than `min`, centred across and in the height between the menu bar
/// and the Dock. Centring in the whole display put the bottom of a window
/// under the Dock on a 900 pt high one.
pub fn first_run(screen: &Rect, size: (f64, f64), min: (f64, f64)) -> WindowFrame {
let width = size.0.min(screen.width - FIT_MARGIN_X).max(min.0);
let room = screen.height - FIRST_RUN_ROOM_ABOVE - FIRST_RUN_ROOM_BELOW;
let height = size.1.min(room).max(min.1);
WindowFrame {
x: screen.x + (screen.width - width) / 2.0,
y: screen.y + FIRST_RUN_ROOM_ABOVE + ((room - height) / 2.0).max(0.0),
width,
height,
fullscreen: false,
}
}
/// The frame clamped to sane sizes, or None when it holds junk.
pub fn sanitized(self) -> Option<WindowFrame> {
let vals = [self.x, self.y, self.width, self.height];
if vals.iter().any(|v| !v.is_finite()) || self.x.abs() > MAX_EDGE || self.y.abs() > MAX_EDGE {
return None;
}
Some(WindowFrame {
width: self.width.clamp(MIN_WIDTH, MAX_EDGE),
height: self.height.clamp(MIN_HEIGHT, MAX_EDGE),
..self
})
}
/// The frame made to fit the screens it will open on, or None when its
/// title bar is on none of them (the caller then falls back to a default
/// frame). An edge that no screen covers is pulled onto the screen that
/// holds the title bar, shrinking the window when it is too big for it:
/// a frame saved on an external display that is gone would otherwise
/// open with its right edge and resize corner far off screen. An edge
/// that lands on some screen stays put, so a window spanning two
/// displays keeps its span. `min` is the window's minimum inner size.
pub fn fitted_to(&self, screens: &[Rect], min: (f64, f64)) -> Option<WindowFrame> {
let home = *self.title_screen(screens)?;
let top = self.outer_top();
let outer_height = TITLE_BAR + self.height;
let (x, width) = fit_axis(
self.x,
self.width,
(
covers_x(screens, self.x, top, top + outer_height),
covers_x(screens, self.x + self.width, top, top + outer_height),
),
(home.x, home.x + home.width),
FIT_MARGIN_X,
min.0,
);
let (top, outer_height) = fit_axis(
top,
outer_height,
(
covers_y(screens, top, x, x + width),
covers_y(screens, top + outer_height, x, x + width),
),
(home.y, home.y + home.height),
FIT_MARGIN_Y - TITLE_BAR,
min.1 + TITLE_BAR,
);
Some(WindowFrame {
x,
y: top + TITLE_ABOVE_Y,
width,
height: outer_height - TITLE_BAR,
..*self
})
}
fn outer_top(&self) -> f64 {
self.y - TITLE_ABOVE_Y
}
/// The screen the title bar is on, or None when no screen has enough of
/// it to grab (a display unplugged since the last run leaves the frame off
/// screen). The middle of the title bar must be inside the screen and at
/// least `MIN_GRIP` of the bar's width must overlap it; when several
/// qualify, the one holding most of the bar wins.
fn title_screen<'a>(&self, screens: &'a [Rect]) -> Option<&'a Rect> {
let title_y = self.outer_top() + TITLE_BAR / 2.0;
screens
.iter()
.filter(|m| title_y >= m.y && title_y <= m.y + m.height)
.map(|m| {
let grip = (self.x + self.width).min(m.x + m.width) - self.x.max(m.x);
(m, grip)
})
.filter(|(_, grip)| *grip >= MIN_GRIP)
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(m, _)| m)
}
}
/// Whether some screen holds part of the vertical edge at `x` that runs from
/// `top` to `bottom`.
fn covers_x(screens: &[Rect], x: f64, top: f64, bottom: f64) -> bool {
screens
.iter()
.any(|m| x >= m.x && x <= m.x + m.width && top < m.y + m.height && bottom > m.y)
}
/// Whether some screen holds part of the horizontal edge at `y` that runs
/// from `left` to `right`.
fn covers_y(screens: &[Rect], y: f64, left: f64, right: f64) -> bool {
screens
.iter()
.any(|m| y >= m.y && y <= m.y + m.height && left < m.x + m.width && right > m.x)
}
/// One axis of `WindowFrame::fitted_to`. The window runs from `start` for
/// `len`; `covered` says whether some screen holds each end; `home` is the
/// title bar screen's range on this axis. An uncovered end is pulled onto
/// `home`, and the window shrinks (leaving `margin`, never below `min`) when
/// it does not fit; a covered end may stay where it is, on another display.
fn fit_axis(start: f64, len: f64, covered: (bool, bool), home: (f64, f64), margin: f64, min: f64) -> (f64, f64) {
if covered.0 && covered.1 {
return (start, len);
}
let lo = if covered.0 { start.min(home.0) } else { home.0 };
let hi = if covered.1 { (start + len).max(home.1) } else { home.1 };
let len = len.min(hi - lo - margin).max(min);
// `max` last: a window still too big for the screen keeps its start (the
// left edge, or the title bar) on screen rather than its end.
let start = start.min(hi - len).max(lo);
(start, len)
}
/// The first name an unreadable `desktop.json` can be moved to without
/// replacing an earlier recovery copy: `desktop.json.corrupt`, then
/// `desktop.json.corrupt.1` to `.9`, next to it.
/// A name is free only when nothing at all is there: a file, a directory or
/// a dangling symlink left at it is taken, and so is a name that cannot be
/// checked. None when every name is taken.
fn free_aside_name(path: &Path) -> Option<PathBuf> {
(0..ASIDE_SLOTS)
.map(|n| match n {
0 => path.with_extension("json.corrupt"),
n => path.with_extension(format!("json.corrupt.{n}")),
})
.find(|aside| matches!(fs::symlink_metadata(aside), Err(e) if e.kind() == io::ErrorKind::NotFound))
}
impl Config {
/// Where the config lives, or None if the OS has no config directory.
pub fn default_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("Codeman").join("desktop.json"))
}
/// Read the config, and say whether saving back to `path` is safe this
/// run. A missing file is the default. A file that is there but cannot
/// be read (not UTF-8, no permission, a directory, an I/O error) or does
/// not parse is moved aside for recovery, and the defaults are used; it
/// is never silently overwritten with them. It goes to the first free
/// name of `desktop.json.corrupt`, `desktop.json.corrupt.1` to `.9`, so
/// a second unreadable file never replaces the copy kept from the first.
/// If every name is taken or the move fails, the answer is false: the
/// original is still at `path`, and the save at exit must not replace it.
pub fn load_from(path: &Path) -> (Config, bool) {
let text = match fs::read_to_string(path) {
Ok(text) => Some(text),
Err(e) if e.kind() == io::ErrorKind::NotFound => return (Config::default(), true),
Err(_) => None,
};
if let Some(mut config) = text.and_then(|t| serde_json::from_str::<Config>(&t).ok()) {
config.sanitize();
return (config, true);
}
let Some(aside) = free_aside_name(path) else {
eprintln!(
"codeman-desktop: {} could not be read, and {} to .{} are all taken; settings will not be saved this run",
path.display(),
path.with_extension("json.corrupt").display(),
ASIDE_SLOTS - 1
);
return (Config::default(), false);
};
match fs::rename(path, &aside) {
Ok(()) => {
eprintln!(
"codeman-desktop: {} could not be read; moved it to {} and started with defaults",
path.display(),
aside.display()
);
(Config::default(), true)
}
Err(e) => {
eprintln!(
"codeman-desktop: {} could not be read or moved aside ({e}); settings will not be saved this run",
path.display()
);
(Config::default(), false)
}
}
}
/// Write via a temp file and rename, so a crash never leaves half a file.
pub fn save_to(&self, path: &Path) -> io::Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
fs::write(&tmp, text)?;
fs::rename(&tmp, path)
}
fn sanitize(&mut self) {
let mut seen = std::collections::HashSet::new();
self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone()));
self.servers.truncate(MAX_SERVERS);
self.window = self.window.and_then(WindowFrame::sanitized);
}
/// Move (or add) a server to the top of the recent list. Returns the
/// Keychain accounts, as (url, username), of the entries with a username
/// that fall off the end of the list (it keeps `MAX_SERVERS`), for the
/// caller to delete. A different username on `url` itself is not
/// reported: the caller deletes that account before it saves the new
/// one, comparing the names exactly as the Keychain account string
/// does. A delete made after the save could take the new password with
/// it on a Keychain that treats two spellings of a name as one item.
#[must_use = "the returned Keychain accounts must be deleted"]
pub fn remember(&mut self, url: &str, username: Option<&str>, saved: bool) -> Vec<(String, String)> {
self.servers.retain(|s| s.url != url);
self.servers.insert(
0,
ServerEntry {
url: url.to_string(),
username: username.map(str::to_string),
has_saved_password: saved,
},
);
let evicted = self.servers.split_off(self.servers.len().min(MAX_SERVERS));
evicted.into_iter().filter_map(|s| Some((s.url, s.username?))).collect()
}
pub fn forget(&mut self, url: &str) -> Option<ServerEntry> {
let idx = self.servers.iter().position(|s| s.url == url)?;
Some(self.servers.remove(idx))
}
pub fn find(&self, url: &str) -> Option<&ServerEntry> {
self.servers.iter().find(|s| s.url == url)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("codeman-desktop-test-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
fn frame(x: f64, y: f64, width: f64, height: f64) -> WindowFrame {
WindowFrame {
x,
y,
width,
height,
fullscreen: false,
}
}
fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect {
Rect { x, y, width, height }
}
const MIN: (f64, f64) = (MIN_WIDTH, MIN_HEIGHT);
fn reachable(f: &WindowFrame, screens: &[Rect]) -> bool {
f.title_screen(screens).is_some()
}
/// The outer frame (title bar included) as left, top, right, bottom.
fn outer(f: &WindowFrame) -> (f64, f64, f64, f64) {
let top = f.y - TITLE_ABOVE_Y;
(f.x, top, f.x + f.width, top + TITLE_BAR + f.height)
}
#[test]
fn missing_file_is_default() {
let dir = tmp_dir("missing");
let (c, can_save) = Config::load_from(&dir.join("desktop.json"));
assert_eq!(c, Config::default());
assert!(c.auto_connect);
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");
let path = dir.join("nested").join("desktop.json");
let mut c = Config::default();
assert!(c.remember("http://127.0.0.1:3000/", None, false).is_empty());
assert!(c.remember("https://box.ts.net/", Some("admin"), true).is_empty());
c.start_fullscreen = true;
c.window = Some(WindowFrame {
x: 10.0,
y: 20.0,
width: 1200.0,
height: 800.0,
fullscreen: true,
});
c.save_to(&path).unwrap();
assert!(!path.with_extension("json.tmp").exists());
let (back, can_save) = Config::load_from(&path);
assert!(can_save);
assert_eq!(back, c);
assert_eq!(back.servers[0].url, "https://box.ts.net/");
let text = fs::read_to_string(&path).unwrap();
assert!(
!text.to_lowercase().contains("password\":\""),
"no password values on disk"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_file_is_moved_aside() {
let dir = tmp_dir("corrupt");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
fs::write(&path, "{ not json").unwrap();
let (c, can_save) = Config::load_from(&path);
assert_eq!(c, Config::default());
assert!(can_save);
assert!(!path.exists());
assert!(dir.join("desktop.json.corrupt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn non_utf8_file_is_moved_aside() {
let dir = tmp_dir("non-utf8");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
// Valid JSON apart from a Latin-1 "ü", which is not UTF-8.
let bytes = b"{\"servers\":[{\"url\":\"http://a/\",\"username\":\"J\xfcrg\"}]}";
fs::write(&path, bytes).unwrap();
let (c, can_save) = Config::load_from(&path);
assert_eq!(c, Config::default());
assert!(can_save);
assert!(!path.exists());
assert_eq!(fs::read(dir.join("desktop.json.corrupt")).unwrap(), bytes);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn unreadable_path_is_moved_aside() {
let dir = tmp_dir("unreadable");
let path = dir.join("desktop.json");
// A directory where the file should be: it exists but cannot be read.
fs::create_dir_all(path.join("inside")).unwrap();
let (c, can_save) = Config::load_from(&path);
assert_eq!(c, Config::default());
assert!(can_save);
assert!(!path.exists());
assert!(dir.join("desktop.json.corrupt").join("inside").is_dir());
c.save_to(&path).unwrap();
assert_eq!(Config::load_from(&path), (Config::default(), true));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn second_corruption_keeps_the_first_copy() {
let dir = tmp_dir("corrupt-twice");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
fs::write(&path, "{ first").unwrap();
assert_eq!(Config::load_from(&path), (Config::default(), true));
fs::write(&path, "{ second").unwrap();
assert_eq!(Config::load_from(&path), (Config::default(), true));
assert!(!path.exists());
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt")).unwrap(), "{ first");
assert_eq!(
fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(),
"{ second"
);
// A good file written afterwards loads normally and moves nothing.
Config::default().save_to(&path).unwrap();
assert_eq!(Config::load_from(&path), (Config::default(), true));
assert!(!dir.join("desktop.json.corrupt.2").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn taken_aside_names_are_skipped() {
let dir = tmp_dir("aside-taken");
// A directory left at the first name and a file at the second: the
// third is the first free one.
fs::create_dir_all(dir.join("desktop.json.corrupt")).unwrap();
fs::write(dir.join("desktop.json.corrupt.1"), "kept").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(dir.join("nowhere"), dir.join("desktop.json.corrupt.2")).unwrap();
let next = if cfg!(unix) {
"desktop.json.corrupt.3"
} else {
"desktop.json.corrupt.2"
};
let path = dir.join("desktop.json");
fs::write(&path, "{ not json").unwrap();
assert_eq!(Config::load_from(&path), (Config::default(), true));
assert!(!path.exists());
assert!(dir.join("desktop.json.corrupt").is_dir());
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), "kept");
// The dangling symlink still points nowhere and still counts as taken.
#[cfg(unix)]
assert!(fs::symlink_metadata(dir.join("desktop.json.corrupt.2"))
.unwrap()
.is_symlink());
assert_eq!(fs::read_to_string(dir.join(next)).unwrap(), "{ not json");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn no_free_aside_name_disables_saving() {
let dir = tmp_dir("aside-full");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
let names: Vec<PathBuf> = std::iter::once(dir.join("desktop.json.corrupt"))
.chain((1..ASIDE_SLOTS).map(|n| dir.join(format!("desktop.json.corrupt.{n}"))))
.collect();
assert_eq!(names.len(), 10);
for (n, name) in names.iter().enumerate() {
fs::write(name, format!("copy {n}")).unwrap();
}
fs::write(&path, "{ not json").unwrap();
assert_eq!(Config::load_from(&path), (Config::default(), false));
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
for (n, name) in names.iter().enumerate() {
assert_eq!(fs::read_to_string(name).unwrap(), format!("copy {n}"));
}
assert!(!dir.join("desktop.json.corrupt.10").exists());
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn failed_move_aside_disables_saving() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("stuck");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
fs::write(&path, "{ not json").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores the read-only directory, so the rename cannot fail.
if fs::write(dir.join("probe"), "").is_ok() {
eprintln!("skipped: the directory is still writable (running as root?)");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
let _ = fs::remove_dir_all(&dir);
return;
}
let (c, can_save) = Config::load_from(&path);
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(c, Config::default());
assert!(!can_save);
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
assert!(!dir.join("desktop.json.corrupt").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn recent_list_is_deduped_and_capped() {
let mut c = Config::default();
for i in 0..12 {
assert!(c.remember(&format!("http://h{i}/"), None, false).is_empty());
}
assert!(c.remember("http://h5/", Some("me"), false).is_empty());
assert_eq!(c.servers.len(), MAX_SERVERS);
assert_eq!(c.servers[0].url, "http://h5/");
assert_eq!(c.servers.iter().filter(|s| s.url == "http://h5/").count(), 1);
assert_eq!(c.forget("http://h5/").unwrap().username.as_deref(), Some("me"));
assert!(c.find("http://h5/").is_none());
}
#[test]
fn remember_leaves_a_username_change_to_the_caller() {
let url = "https://box.ts.net/";
let mut c = Config::default();
assert!(c.remember(url, Some("Alice"), true).is_empty());
// Every change of the username on the same server, a case-only one,
// another user, or none at all, returns nothing: the caller deletes
// the old account before it saves the new one.
for (user, saved) in [
(Some("alice"), true),
(Some(" alice "), true),
(Some("bob"), true),
(None, false),
(None, false),
(Some("dave"), true),
] {
assert!(c.remember(url, user, saved).is_empty(), "{user:?}");
let entry = c.find(url).unwrap();
assert_eq!((entry.username.as_deref(), entry.has_saved_password), (user, saved));
assert_eq!(c.servers.iter().filter(|s| s.url == url).count(), 1);
}
assert!(c.remember("http://other/", Some("carol"), true).is_empty());
assert!(c.remember(url, Some("erin"), true).is_empty());
assert_eq!(c.servers.len(), 2);
assert_eq!(c.servers[0].url, url);
assert_eq!(c.find("http://other/").unwrap().username.as_deref(), Some("carol"));
}
#[test]
fn remember_returns_evicted_accounts() {
let mut c = Config::default();
for i in 0..MAX_SERVERS {
let user = (i % 2 == 0).then(|| format!("u{i}"));
assert!(c.remember(&format!("http://h{i}/"), user.as_deref(), true).is_empty());
}
// Moving a listed server to the top evicts nothing.
assert!(c.remember("http://h3/", None, false).is_empty());
assert_eq!(c.servers.len(), MAX_SERVERS);
// A new server pushes the oldest (h0, with a username) off the end.
assert_eq!(
c.remember("http://new/", None, false),
vec![("http://h0/".to_string(), "u0".to_string())]
);
// The next oldest is h1, which has no username: nothing to delete.
assert!(c.remember("http://newer/", Some("x"), true).is_empty());
assert_eq!(c.servers.len(), MAX_SERVERS);
assert!(c.find("http://h0/").is_none() && c.find("http://h1/").is_none());
// A username change on a listed server returns nothing; the next new
// server evicts the oldest entry, and only that account comes back.
let mut c = Config::default();
for i in 0..MAX_SERVERS {
assert!(c.remember(&format!("http://h{i}/"), Some("u"), true).is_empty());
}
assert!(c.remember("http://h7/", Some("v"), true).is_empty());
assert_eq!(
c.remember("http://h8/", Some("w"), true),
vec![("http://h0/".to_string(), "u".to_string())]
);
assert_eq!(c.find("http://h7/").unwrap().username.as_deref(), Some("v"));
assert_eq!(c.servers.len(), MAX_SERVERS);
}
#[test]
fn unknown_and_missing_fields_tolerated() {
let c: Config = serde_json::from_str(r#"{"servers":[{"url":"http://a/"}],"future":1}"#).unwrap();
assert!(c.auto_connect);
assert_eq!(c.servers[0].username, None);
}
#[test]
fn a_bad_window_frame_keeps_the_servers() {
for (name, window) in [
("missing-height", r#"{"x":1,"y":2,"width":3}"#),
("not-a-frame", r#""maximized""#),
("null-number", r#"{"x":null,"y":2,"width":3,"height":4}"#),
] {
let dir = tmp_dir(&format!("bad-window-{name}"));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("desktop.json");
fs::write(
&path,
format!(r#"{{"servers":[{{"url":"http://a/"}}],"autoConnect":false,"window":{window}}}"#),
)
.unwrap();
let (c, can_save) = Config::load_from(&path);
assert!(can_save, "{name}");
assert_eq!(c.servers.len(), 1, "{name}");
assert!(!c.auto_connect, "{name}");
assert_eq!(c.window, None, "{name}");
// The file was read, so it stays where it is.
assert!(path.is_file(), "{name}");
assert!(!path.with_extension("json.corrupt").exists(), "{name}");
}
// A whole frame and an absent one read as before.
let c: Config =
serde_json::from_str(r#"{"window":{"x":1,"y":2,"width":900,"height":600,"fullscreen":true}}"#).unwrap();
assert_eq!(c.window.map(|f| (f.width, f.fullscreen)), Some((900.0, true)));
assert_eq!(serde_json::from_str::<Config>("{}").unwrap().window, None);
assert_eq!(
serde_json::from_str::<Config>(r#"{"window":null}"#).unwrap().window,
None
);
}
#[test]
fn first_run_window_sits_between_the_menu_bar_and_the_dock() {
let screen = |width: f64, height: f64| Rect {
x: 0.0,
y: 0.0,
width,
height,
};
let size = (1280.0, 820.0);
let min = (480.0, 360.0);
// A 13 inch MacBook: the window is shortened, and ends above the Dock.
let f = WindowFrame::first_run(&screen(1440.0, 900.0), size, min);
assert_eq!((f.width, f.height), (1280.0, 744.0));
assert_eq!((f.x, f.y), (80.0, 66.0));
assert!(f.y + f.height <= 900.0 - 90.0);
// A large display: the full size, centred across and in the free height.
let f = WindowFrame::first_run(&screen(2560.0, 1440.0), size, min);
assert_eq!((f.width, f.height), (1280.0, 820.0));
assert_eq!(f.x, 640.0);
assert_eq!(f.y - 66.0, 1440.0 - 90.0 - (f.y + f.height));
// A second display to the left of the first.
let left = Rect {
x: -1920.0,
y: 120.0,
width: 1920.0,
height: 1080.0,
};
let f = WindowFrame::first_run(&left, size, min);
assert_eq!(f.x, -1920.0 + 320.0);
assert!(f.y >= 120.0 + 66.0 && f.y + f.height <= 120.0 + 1080.0 - 90.0);
// Never smaller than the minimum, on a display too small for it too.
let f = WindowFrame::first_run(&screen(500.0, 400.0), size, min);
assert_eq!((f.width, f.height), min);
assert!(!f.fullscreen);
}
#[test]
fn frame_sanitizing_and_reachability() {
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
let small = frame(0.0, 0.0, 10.0, 10.0).sanitized().unwrap();
assert_eq!((small.width, small.height), (MIN_WIDTH, MIN_HEIGHT));
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
let on = frame(100.0, 50.0, 1200.0, 800.0);
assert!(reachable(&on, &screen));
assert_eq!(on.fitted_to(&screen, MIN), Some(on));
let off = frame(3000.0, 50.0, 1200.0, 800.0);
assert!(!reachable(&off, &screen));
assert_eq!(off.fitted_to(&screen, MIN), None);
let above = frame(100.0, -900.0, 1200.0, 800.0);
assert!(!reachable(&above, &screen));
assert_eq!(above.fitted_to(&screen, MIN), None);
// Only a sliver of the title bar on screen is not enough to grab.
let sliver = frame(-1150.0, 50.0, 1200.0, 800.0);
assert!(!reachable(&sliver, &screen));
assert_eq!(sliver.fitted_to(&screen, MIN), None);
// No screens at all (none reported): nothing is reachable.
assert_eq!(on.fitted_to(&[], MIN), None);
}
#[test]
fn title_bar_is_above_y_on_macos() {
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
// On macOS the title bar of a content top at y = 10 is at -18..10,
// mostly above the screen; elsewhere it is at 10..38.
let high = frame(100.0, 10.0, 1200.0, 800.0);
assert_eq!(reachable(&high, &screen), !cfg!(target_os = "macos"));
// Half the bar above the top edge: still grabbable, and fitting
// brings all of it onto the screen.
let straddling = frame(100.0, TITLE_ABOVE_Y - 10.0, 1200.0, 800.0);
assert!(reachable(&straddling, &screen));
let fitted = straddling.fitted_to(&screen, MIN).unwrap();
assert_eq!(outer(&fitted).1, 0.0);
assert_eq!((fitted.x, fitted.width, fitted.height), (100.0, 1200.0, 800.0));
}
#[test]
fn frame_from_an_unplugged_ultrawide_is_fitted() {
// Saved on a 3440x1440 display at the origin, restored on a laptop.
let laptop = [rect(0.0, 0.0, 1512.0, 982.0)];
let wide = frame(0.0, 30.0, 3300.0, 1300.0);
let fitted = wide.fitted_to(&laptop, MIN).unwrap();
assert_eq!(
(fitted.x, fitted.y, fitted.width, fitted.height),
(0.0, 30.0, 1512.0 - FIT_MARGIN_X, 982.0 - FIT_MARGIN_Y)
);
let (left, top, right, bottom) = outer(&fitted);
assert!(left >= 0.0 && top >= 0.0 && right <= 1512.0 && bottom <= 982.0);
// Hanging off the right of the laptop by a bit: shifted, not shrunk.
let fitted = frame(500.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
assert_eq!(
(fitted.x, fitted.y, fitted.width, fitted.height),
(312.0, 100.0, 1200.0, 700.0)
);
// Hanging off the left: shifted right.
let fitted = frame(-1000.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
assert_eq!((fitted.x, fitted.width), (0.0, 1200.0));
}
#[test]
fn frame_spanning_two_displays_is_left_alone() {
let screens = [rect(0.0, 0.0, 1512.0, 982.0), rect(1512.0, -200.0, 2560.0, 1440.0)];
let span = frame(1000.0, 100.0, 1500.0, 700.0);
assert_eq!(span.fitted_to(&screens, MIN), Some(span));
// Only the edge that lands on no display moves: the left edge stays
// on the laptop, the right edge comes back onto the external one.
let past = frame(1000.0, 100.0, 3500.0, 700.0);
let fitted = past.fitted_to(&screens, MIN).unwrap();
assert_eq!(fitted.x, 1000.0);
assert_eq!(fitted.x + fitted.width, 1512.0 + 2560.0 - FIT_MARGIN_X);
assert_eq!((fitted.y, fitted.height), (100.0, 700.0));
}
#[test]
fn fitted_frame_keeps_the_minimum_size() {
let tiny = [rect(0.0, 0.0, 400.0, 300.0)];
let fitted = frame(0.0, 40.0, 1200.0, 800.0).fitted_to(&tiny, MIN).unwrap();
assert_eq!((fitted.width, fitted.height), MIN);
// Too big for the screen even at the minimum: the left edge and the
// title bar stay on it.
let (left, top, _, _) = outer(&fitted);
assert_eq!((left, top), (0.0, 0.0));
assert!(reachable(&fitted, &tiny));
// The fullscreen flag rides along.
let full = WindowFrame {
fullscreen: true,
..frame(0.0, 40.0, 1200.0, 800.0)
};
assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen);
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Saved passwords, in the macOS Keychain (service "Codeman Desktop", one item
//! per username + server). Nothing secret is ever written to the config file.
//! Windows and Linux get their stores when those builds land.
//!
//! Every Keychain call runs on ONE long-lived worker thread, never on the
//! event loop: any of them can bring up the system's access prompt (an
//! ad-hoc build's items name the previous build), and the window must keep
//! running while it is up. One serial worker rather than a thread per call,
//! because jobs must run in the order they were asked for: a delete from
//! Forget that ran after a later save of the same account would remove the
//! password that was just saved.
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};
pub const SERVICE: &str = "Codeman Desktop";
/// What a job reports when the worker thread is gone.
const WORKER_GONE: &str = "The Keychain could not be reached.";
/// The Keychain account name for a username on a server.
pub fn account(server_url: &str, username: &str) -> String {
format!("{username} @ {server_url}")
}
/// The password store the worker runs jobs against: the Keychain on macOS,
/// a stub elsewhere, an in-memory map in the tests.
trait Backend: Send {
fn load(&mut self, account: &str) -> Option<String>;
fn save(&mut self, account: &str, password: &str) -> Result<(), String>;
fn delete(&mut self, account: &str);
}
enum Job {
Load {
account: String,
reply: Sender<Option<String>>,
},
Save {
account: String,
password: String,
reply: Sender<Result<(), String>>,
},
Delete {
account: String,
},
/// Ends the worker, so the tests can check what callers see after it.
#[cfg(test)]
Stop,
}
/// A handle on the Keychain worker. Start it once with [`Keychain::spawn`]
/// and clone the handle: every clone feeds the same queue, which is what
/// keeps jobs in order. The worker ends when the last handle is dropped.
#[derive(Clone)]
pub struct Keychain {
jobs: Sender<Job>,
}
impl Keychain {
/// Starts the worker. If the thread cannot be started, the handle still
/// works and every call fails soft (no password, save refused).
pub fn spawn() -> Keychain {
start(Box::new(imp::System)).0
}
/// The saved password for an account. Blocks until every job queued
/// before it has run and the Keychain has answered, which can mean
/// waiting on the access prompt, so call it from a worker thread.
/// `None` when nothing is saved, the item is unreadable or the worker
/// is gone.
pub fn load_blocking(&self, account: &str) -> Option<String> {
let (reply, answer) = mpsc::channel();
let job = Job::Load {
account: account.to_owned(),
reply,
};
self.jobs.send(job).ok()?;
answer.recv().ok().flatten()
}
/// Saves a password, replacing any saved before. Blocks like
/// [`Keychain::load_blocking`]; the result is the Keychain's own.
pub fn save_blocking(&self, account: &str, password: &str) -> Result<(), String> {
let (reply, answer) = mpsc::channel();
let job = Job::Save {
account: account.to_owned(),
password: password.to_owned(),
reply,
};
self.jobs.send(job).map_err(|_| WORKER_GONE.to_string())?;
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
}
/// Queues the removal of an account's password and returns at once.
/// Jobs queued after it see the account gone. Nothing happens when the
/// worker is gone.
pub fn delete(&self, account: &str) {
let _ = self.jobs.send(Job::Delete {
account: account.to_owned(),
});
}
}
/// Starts a worker on `backend`. The join handle is for the tests; `None`
/// when the thread could not be started, in which case the receiver went
/// down with the closure and every send fails.
fn start(backend: Box<dyn Backend>) -> (Keychain, Option<JoinHandle<()>>) {
let (jobs, queue) = mpsc::channel();
let worker = thread::Builder::new()
.name("keychain".into())
.spawn(move || run(backend, queue))
.ok();
(Keychain { jobs }, worker)
}
/// The worker loop: one job at a time, in the order they were sent, until
/// every handle is gone. A reply nobody waits for any more is dropped.
fn run(mut backend: Box<dyn Backend>, queue: Receiver<Job>) {
for job in queue {
match job {
Job::Load { account, reply } => {
let _ = reply.send(backend.load(&account));
}
Job::Save {
account,
password,
reply,
} => {
let _ = reply.send(backend.save(&account, &password));
}
Job::Delete { account } => backend.delete(&account),
#[cfg(test)]
Job::Stop => return,
}
}
}
#[cfg(target_os = "macos")]
mod imp {
use super::{Backend, SERVICE};
use objc2::rc::autoreleasepool;
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
/// The login Keychain, through security-framework. The worker lives as
/// long as the app and a thread's implicit autorelease pool drains only
/// when the thread ends, so each call gets its own pool and whatever
/// Security autoreleases is freed with the job.
pub struct System;
impl Backend for System {
fn load(&mut self, account: &str) -> Option<String> {
autoreleasepool(|_| {
get_generic_password(SERVICE, account)
.ok()
.and_then(|b| String::from_utf8(b).ok())
})
}
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
autoreleasepool(|_| set_generic_password(SERVICE, account, password.as_bytes()).map_err(|e| e.to_string()))
}
fn delete(&mut self, account: &str) {
autoreleasepool(|_| {
let _ = delete_generic_password(SERVICE, account);
})
}
}
}
#[cfg(not(target_os = "macos"))]
mod imp {
use super::Backend;
/// No password store yet on this platform.
pub struct System;
impl Backend for System {
fn load(&mut self, _account: &str) -> Option<String> {
None
}
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
Err("Saving passwords is not supported on this platform yet.".into())
}
fn delete(&mut self, _account: &str) {}
}
}
/// A Keychain on an in-memory store, for these tests and for the tests of the
/// code that uses a Keychain (main.rs).
#[cfg(test)]
pub(crate) mod memory {
use super::{start, Backend, Keychain};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// An in-memory store that logs every job it runs. Its delete is slow on
/// purpose: a queue that did not keep order would let the next job
/// overtake it, and the tests would see that.
#[derive(Clone, Default)]
pub(crate) struct Memory {
items: Arc<Mutex<HashMap<String, String>>>,
log: Arc<Mutex<Vec<String>>>,
}
impl Memory {
fn note(&self, entry: String) {
self.log.lock().unwrap().push(entry);
}
/// The jobs the store ran, in order.
pub(crate) fn log(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
}
impl Backend for Memory {
fn load(&mut self, account: &str) -> Option<String> {
self.note(format!("load {account}"));
self.items.lock().unwrap().get(account).cloned()
}
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
self.note(format!("save {account}"));
self.items
.lock()
.unwrap()
.insert(account.to_owned(), password.to_owned());
Ok(())
}
fn delete(&mut self, account: &str) {
std::thread::sleep(Duration::from_millis(30));
self.note(format!("delete {account}"));
self.items.lock().unwrap().remove(account);
}
}
/// A worker on a fresh store, and the store to look into.
pub(crate) fn keychain() -> (Keychain, Memory) {
let memory = Memory::default();
let (keychain, worker) = start(Box::new(memory.clone()));
assert!(worker.is_some(), "the worker thread starts");
(keychain, memory)
}
}
#[cfg(test)]
mod tests {
use super::memory::{keychain, Memory};
use super::{start, Backend, Job, Keychain};
/// A store whose load fails the way a crashed worker would.
struct Crashes;
impl Backend for Crashes {
fn load(&mut self, _account: &str) -> Option<String> {
panic!("keychain worker crashed");
}
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
Ok(())
}
fn delete(&mut self, _account: &str) {}
}
#[test]
fn account_names_are_per_user_and_server() {
assert_eq!(
super::account("http://127.0.0.1:3000/", "admin"),
"admin @ http://127.0.0.1:3000/"
);
assert_ne!(
super::account("http://a/", "admin"),
super::account("http://b/", "admin")
);
}
#[test]
fn a_load_reads_what_a_save_wrote() {
let (keychain, _) = keychain();
assert_eq!(keychain.load_blocking("a"), None);
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
assert_eq!(keychain.load_blocking("a").as_deref(), Some("secret"));
assert_eq!(keychain.save_blocking("a", "changed"), Ok(()));
assert_eq!(keychain.load_blocking("a").as_deref(), Some("changed"));
assert_eq!(keychain.load_blocking("b"), None);
}
#[test]
fn a_delete_queued_before_a_save_never_removes_the_saved_password() {
let (keychain, memory) = keychain();
assert_eq!(keychain.save_blocking("a", "old"), Ok(()));
keychain.delete("a");
assert_eq!(keychain.save_blocking("a", "new"), Ok(()));
assert_eq!(keychain.load_blocking("a").as_deref(), Some("new"));
assert_eq!(memory.log(), ["save a", "delete a", "save a", "load a"]);
}
#[test]
fn a_delete_queued_after_a_save_removes_it_before_the_next_job() {
let (keychain, memory) = keychain();
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
keychain.delete("a");
// No wait between the two: the load is queued behind the delete.
assert_eq!(keychain.load_blocking("a"), None);
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
}
#[test]
fn jobs_from_clones_on_other_threads_share_one_queue() {
let (keychain, memory) = keychain();
let other = keychain.clone();
std::thread::spawn(move || {
other.save_blocking("a", "secret").unwrap();
other.delete("a");
})
.join()
.unwrap();
assert_eq!(keychain.load_blocking("a"), None);
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
}
#[test]
fn every_call_fails_soft_once_the_worker_is_gone() {
let memory = Memory::default();
let (keychain, worker) = start(Box::new(memory.clone()));
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
keychain.jobs.send(Job::Stop).unwrap();
worker.unwrap().join().unwrap();
assert_eq!(keychain.load_blocking("a"), None);
assert!(keychain.save_blocking("a", "secret").is_err());
keychain.delete("a");
keychain.clone().delete("a");
assert_eq!(memory.log(), ["save a"]);
}
#[cfg(panic = "unwind")]
#[test]
fn a_worker_that_dies_mid_job_answers_nothing_and_takes_no_more_jobs() {
let (keychain, worker) = start(Box::new(Crashes));
assert_eq!(keychain.load_blocking("a"), None);
assert!(worker.unwrap().join().is_err(), "the worker panicked");
assert!(keychain.save_blocking("a", "secret").is_err());
keychain.delete("a");
}
#[test]
fn the_handle_is_send_and_sync() {
fn send_sync_clone<T: Send + Sync + Clone + 'static>() {}
send_sync_clone::<Keychain>();
}
}
File diff suppressed because it is too large. Load diff
+852
View File
@@ -0,0 +1,852 @@
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
//! The same login-shell machinery answers whether Codeman is installed
//! (`installed`) and hands its environment to the graphical install
//! (`login_environment`); `exec_bounded` is the crate's bounded process runner.
//!
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
//! finds neither `codeman` nor `node`, and the server inherits whatever
//! environment it is started with: every session it spawns looks for
//! claude/codex/tmux on that PATH and reads provider keys from it. So the
//! command runs with the login shell's environment, picking up Homebrew and the
//! user's profile, and falls back to the interactive profile (`-i`) for setups
//! that only extend PATH there (nvm in `.zshrc`). The URL's port and base path
//! are passed as separate arguments (`"$@"`), never spliced into the script text.
//!
//! How depends on the shell, chosen by its basename as Codeman's own
//! `src/utils/shell-resolver.ts` does:
//! - A POSIX shell (zsh, bash, sh, ...) runs `SCRIPT` itself:
//! `$SHELL -l [-i] -c SCRIPT codeman-desktop <args>`.
//! - Any other shell (fish, nu, xonsh, elvish, pwsh, tcsh) cannot parse
//! `SCRIPT`: fish has no `then`/`fi` and refuses `$@`. It is only asked to
//! print its environment between two markers, and `SCRIPT` then runs under
//! `/bin/sh` with exactly that environment.
//!
//! `codeman web -d` refuses to start a second server on the same data dir and
//! returns once the new one answers `/api/status`, so this is safe to call
//! only after the probe found nothing listening.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use url::Url;
use crate::policy;
/// For the whole start, every shell run and retry included.
const TIMEOUT: Duration = Duration::from_secs(60);
/// How long the pipes may stay open after the shell exited. Something the
/// profile started in the background can inherit them and keep them open for
/// as long as it runs; whatever arrived by then is used.
const PIPE_GRACE: Duration = Duration::from_secs(2);
/// Found on PATH first, then the `install.sh` clone at `~/.codeman/app`.
const SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exec codeman "$@"; fi
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then
exec node "$HOME/.codeman/app/dist/index.js" "$@"
fi
echo "Could not find the codeman command or ~/.codeman/app. Install Codeman first: https://github.com/Ark0N/Codeman#install" >&2
exit 127"#;
/// Bracket the environment a non-POSIX login shell prints, so whatever its
/// profile writes to stdout before or after cannot be read as a variable.
const ENV_BEGIN: &str = "__CODEMAN_DESKTOP_ENV_BEGIN__";
const ENV_END: &str = "__CODEMAN_DESKTOP_ENV_END__";
/// What a non-POSIX login shell runs to report its environment. The shell
/// itself only parses a command path and one single-quoted word with no `;`,
/// `$`, backslash or `!` in it, so fish, nu, xonsh, elvish, pwsh and tcsh have
/// no syntax of their own to disagree on. The markers and `env` run under
/// `/bin/sh`, which inherits everything the profile exported.
fn capture_command() -> String {
format!("/bin/sh -c 'echo {ENV_BEGIN}; /usr/bin/env; echo {ENV_END}'")
}
fn login_shell() -> PathBuf {
std::env::var_os("SHELL")
.map(PathBuf::from)
.filter(|p| p.is_absolute() && p.exists())
.unwrap_or_else(|| PathBuf::from("/bin/zsh"))
}
/// How the login shell takes part in a start.
#[derive(Debug, PartialEq, Eq)]
enum Flavor {
/// Runs `SCRIPT` itself.
Posix,
/// Only reports its environment. One entry per attempt, in order: a later
/// one runs when the earlier one printed no environment or its environment
/// had no `codeman` (exit 127).
Capture(&'static [&'static [&'static str]]),
}
fn flavor(shell: &Path) -> Flavor {
match shell.file_name().and_then(|n| n.to_str()).unwrap_or("") {
"sh" | "bash" | "zsh" | "dash" | "ksh" | "ash" | "ksh93" | "mksh" | "pdksh" => Flavor::Posix,
// tcsh honors -l only when it is the ONLY flag, so a login shell that
// also runs a command is impossible; -i at least reads all of .tcshrc
// (csh: .cshrc), including the parts guarded by `$?prompt`.
"csh" | "tcsh" => Flavor::Capture(&[&["-ic"]]),
_ => Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
}
}
/// What one process printed, stdout and stderr kept apart.
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.
struct Run {
code: Option<i32>,
output: String,
}
impl From<Output> for Run {
fn from(out: Output) -> Self {
let mut output = out.stdout;
if !out.stderr.trim().is_empty() {
// On a line of its own, also when stdout ended without a newline.
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&out.stderr);
}
Run {
code: out.code,
output: last_lines(&output, 6),
}
}
}
/// Copy `pipe` into the channel as it arrives, tagged with `stream`, then send
/// `None` once it closes.
fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Sender<(usize, Option<Vec<u8>>)>) {
std::thread::spawn(move || {
if let Some(mut pipe) = pipe {
let mut buf = [0u8; 8192];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if tx.send((stream, Some(buf[..n].to_vec()))).is_err() {
return;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}
let _ = tx.send((stream, None));
});
}
/// `exec_bounded` with the start's timeout message. The installed check and
/// `login_environment` run through it too, and drop its error.
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()
.map_err(|e| format!("Could not run {}: {e}", Path::new(cmd.get_program()).display()))?;
// Read both pipes on threads so a chatty profile cannot fill one and stall.
let (tx, rx) = mpsc::channel();
pump(child.stdout.take(), 0, tx.clone());
pump(child.stderr.take(), 1, tx);
// A login shell usually answers within tens of milliseconds: look often
// at first, then back off.
let mut nap = Duration::from_millis(5);
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(nap);
nap = (nap * 2).min(Duration::from_millis(100));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(timed_out.to_string());
}
Err(e) => return Err(e.to_string()),
}
};
let mut bufs = [Vec::new(), Vec::new()];
let settle = (Instant::now() + PIPE_GRACE).min(deadline);
let mut open = 2;
while open > 0 {
match rx.recv_timeout(settle.saturating_duration_since(Instant::now())) {
Ok((stream, Some(bytes))) => bufs[stream].extend_from_slice(&bytes),
Ok((_, None)) => open -= 1,
Err(_) => {
// Still held open past the grace: keep what already arrived.
for (stream, bytes) in rx.try_iter() {
if let Some(bytes) = bytes {
bufs[stream].extend_from_slice(&bytes);
}
}
break;
}
}
}
// Lossy rather than strict: one stray non-UTF-8 byte in a profile's output
// must not cost the whole environment.
let [out, err] = bufs;
Ok(Output {
code: status.code(),
stdout: String::from_utf8_lossy(&out).into_owned(),
stderr: String::from_utf8_lossy(&err).into_owned(),
})
}
/// The tail of what the command printed, for the connect page.
fn last_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text
.lines()
.map(str::trim_end)
.filter(|l| !l.trim().is_empty())
.collect();
lines[lines.len().saturating_sub(n)..].join("\n")
}
/// The variables `env` printed between the markers, or None when the output
/// does not hold both. A line that does not start with a portable variable
/// name followed by `=` continues the previous value (a value with a newline
/// in it); one that does starts the next variable.
fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
let mut lines = output.split('\n');
// ends_with: a profile line printed without its newline sits in front.
lines.by_ref().find(|l| l.trim_end().ends_with(ENV_BEGIN))?;
let mut vars: Vec<(String, String)> = Vec::new();
for line in lines {
if line.trim() == ENV_END {
return Some(vars);
}
match env_entry(line) {
Some((name, value)) => vars.push((name.to_string(), value.to_string())),
None => {
if let Some((_, value)) = vars.last_mut() {
value.push('\n');
value.push_str(line);
}
}
}
}
None
}
/// A portable variable name: a letter or `_`, then letters, digits or `_`.
pub fn is_env_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn env_entry(line: &str) -> Option<(&str, &str)> {
let (name, value) = line.split_once('=')?;
is_env_name(name).then_some((name, value))
}
/// Ask `shell` for its environment with `flags`. The outer error ends the
/// start (the shell could not be run, or time is up); the inner one means this
/// form printed no environment and the next may be tried.
fn capture_env(
shell: &Path,
flags: &[&str],
deadline: Instant,
) -> Result<Result<Vec<(String, String)>, String>, String> {
let mut cmd = Command::new(shell);
cmd.args(flags).arg(capture_command());
let out = exec(cmd, deadline)?;
Ok(parse_env(&out.stdout).ok_or_else(|| {
// stderr only: stdout can hold part of the environment, API keys included.
let said = last_lines(&out.stderr, 6);
let mut message = format!("Could not read the environment of your login shell {}", shell.display());
if said.is_empty() {
let code = out
.code
.map_or_else(|| "a signal".to_string(), |c| format!("exit code {c}"));
message.push_str(&format!(" (it ended with {code})."));
} else {
message.push_str(":\n");
message.push_str(&said);
}
message
}))
}
/// 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
/// or the Dock runs in `/`.
fn in_home(cmd: &mut Command) {
if let Some(home) = dirs::home_dir() {
cmd.current_dir(home);
}
}
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
/// settings and nothing of launchd's bare one.
fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadline: Instant) -> Result<Run, String> {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
in_home(&mut cmd);
exec(cmd, deadline).map(Run::from)
}
/// `script` run by the POSIX login shell itself.
fn run_in_shell(
script: &str,
shell: &Path,
interactive: bool,
args: &[String],
deadline: Instant,
) -> Result<Run, String> {
let mut cmd = Command::new(shell);
cmd.arg("-l");
if interactive {
cmd.arg("-i");
}
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
in_home(&mut cmd);
exec(cmd, deadline).map(Run::from)
}
/// Run `script` the way the login shell allows (see the module docs). Exit
/// 127 means "no codeman here" and moves on to the next way of asking; the
/// interactive profile is asked only when `interactive` allows it.
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
let Flavor::Capture(attempts) = flavor(shell) else {
let result = run_in_shell(script, shell, false, args, deadline)?;
if result.code != Some(127) || !interactive {
return Ok(result);
}
// Not on the login PATH; try the interactive profile too.
return run_in_shell(script, shell, true, args, deadline);
};
let mut last_run = None;
let mut capture_error = None;
let interactive_flag = |flags: &&&[&str]| {
flags
.iter()
.any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('i'))
};
for flags in attempts.iter().filter(|f| interactive || !interactive_flag(f)) {
match capture_env(shell, flags, deadline)? {
Ok(env) => {
let result = run_with_env(script, &env, args, deadline)?;
if result.code != Some(127) {
return Ok(result);
}
last_run = Some(result);
}
Err(e) => capture_error = Some(e),
}
}
if let Some(result) = last_run {
return Ok(result);
}
// No form printed an environment: a shell that knows neither -l nor -i
// may still run a bare -c.
match capture_env(shell, &["-c"], deadline)? {
Ok(env) => run_with_env(script, &env, args, deadline),
Err(e) => Err(capture_error.unwrap_or(e)),
}
}
/// Exit 0 when SCRIPT would find Codeman (the `codeman` command or the
/// `install.sh` clone), 127 when it would not. Same lookup, nothing started.
const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0; fi
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
exit 127"#;
/// How long one look at the login shell may take: whether Codeman is
/// installed (the This Mac tab), or its environment for the graphical install.
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
/// `installed` with the shell given, so tests need not touch `$SHELL`.
fn installed_with(shell: &Path, interactive: bool) -> bool {
matches!(
launch(FIND_SCRIPT, shell, &[], Instant::now() + FIND_TIMEOUT, interactive),
Ok(Run { code: Some(0), .. })
)
}
/// Whether "Start Codeman on this Mac" would find Codeman: the `codeman`
/// command on the login shell's PATH, or `~/.codeman/app` with `node`. Runs
/// the login shell, so never on the event loop. `interactive` also asks the
/// interactive profile (nvm in `.zshrc`, say), whose side effects only a
/// user's own click should set off.
pub fn installed(interactive: bool) -> bool {
installed_with(&login_shell(), interactive)
}
/// `start` with the shell given, so tests need not touch `$SHELL`.
fn start_with(shell: &Path, args: &[String]) -> Result<String, String> {
let result = launch(SCRIPT, shell, args, Instant::now() + TIMEOUT, true)?;
match result.code {
Some(0) => Ok(result.output),
_ if result.output.is_empty() => Err("codeman web -d failed without saying why.".into()),
_ => Err(result.output),
}
}
/// Start a detached server for `url`. Ok carries what `codeman` printed.
pub fn start(url: &Url) -> Result<String, String> {
if !policy::can_start_locally(url) {
return Err("Codeman can only be started here for http://127.0.0.1 or http://localhost on this Mac.".into());
}
start_with(&login_shell(), &policy::start_args(url))
}
#[cfg(test)]
mod tests {
use super::*;
/// One spawning test at a time. A test writes a fake executable and then
/// runs it; a process forked by a parallel test in between can still hold
/// the write descriptor, and the exec then fails with ETXTBSY.
fn serial() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn tail_of_output() {
assert_eq!(last_lines("a\n\nb\nc\n", 2), "b\nc");
assert_eq!(last_lines("", 3), "");
}
#[test]
fn stderr_starts_on_a_line_of_its_own() {
let said = |stdout: &str, stderr: &str| {
Run::from(Output {
code: Some(1),
stdout: stdout.into(),
stderr: stderr.into(),
})
.output
};
// stdout that ends without a newline: stderr still starts a line of its own.
assert_eq!(
said("Starting Codeman", "Error: port 3000 is in use\n"),
"Starting Codeman\nError: port 3000 is in use"
);
assert_eq!(said("Starting\n", "Error\n"), "Starting\nError");
assert_eq!(said("", "Error\n"), "Error");
assert_eq!(said("Started\n", " \n"), "Started");
}
#[test]
fn refuses_non_local_or_https() {
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
assert!(start(&Url::parse("http://192.168.1.4:3000/").unwrap()).is_err());
}
/// The script passes its arguments through untouched and reports a missing
/// `codeman` with exit 127 (run with an empty PATH and HOME so nothing is found).
#[test]
fn script_reports_missing_codeman() {
let _serial = serial();
let out = Command::new("/bin/sh")
.arg("-c")
.arg(SCRIPT)
.arg("codeman-desktop")
.args(["web", "-d", "--port", "3000"])
.env("PATH", "/nonexistent")
.env("HOME", "/nonexistent")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(127));
assert!(String::from_utf8_lossy(&out.stderr).contains("Could not find the codeman command"));
}
/// Arguments are data: a hostile-looking value is passed as one argv entry
/// to a fake `codeman`, never run by the shell.
#[test]
fn arguments_are_not_shell_text() {
let _serial = serial();
let dir = std::env::temp_dir().join(format!("codeman-desktop-fake-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fake = dir.join("codeman");
std::fs::write(&fake, "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\"; done\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let out = Command::new("/bin/sh")
.arg("-c")
.arg(SCRIPT)
.arg("codeman-desktop")
.args(["web", "--base-url", "/x; touch /tmp/pwned"])
.env("PATH", format!("{}:/usr/bin:/bin", dir.display()))
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&out.stdout),
"[web][--base-url][/x; touch /tmp/pwned]"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn pairs(vars: &[(&str, &str)]) -> Vec<(String, String)> {
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn env_capture_parser() {
let out = format!(
"Welcome to fish, the friendly interactive shell\nno newline here{ENV_BEGIN}\n\
PATH=/opt/homebrew/bin:/usr/bin:/bin\n\
MULTI=line one\nline two\n three = 3\n\
EMPTY=\n\
TRAIL=a \n\n\
_=/usr/bin/env\n\
{ENV_END} \nbye from the profile\nNOT_CAPTURED=1\n"
);
assert_eq!(
parse_env(&out),
Some(pairs(&[
("PATH", "/opt/homebrew/bin:/usr/bin:/bin"),
("MULTI", "line one\nline two\n three = 3"),
("EMPTY", ""),
("TRAIL", "a \n"),
("_", "/usr/bin/env"),
]))
);
// A value may itself contain `=`; only the first one splits.
assert_eq!(
parse_env(&format!("{ENV_BEGIN}\nOPTS=a=b\n{ENV_END}\n")),
Some(pairs(&[("OPTS", "a=b")]))
);
assert_eq!(parse_env(&format!("{ENV_BEGIN}\n{ENV_END}\n")), Some(vec![]));
assert_eq!(parse_env(&format!("{ENV_BEGIN}\nPATH=/usr/bin\n")), None);
assert_eq!(parse_env(&format!("PATH=/usr/bin\n{ENV_END}\n")), None);
assert_eq!(parse_env("fish: Unknown command: then\n"), None);
}
#[test]
fn shell_flavor_by_basename() {
for posix in [
"/bin/zsh",
"/bin/bash",
"/bin/sh",
"/bin/dash",
"/bin/ksh",
"/opt/homebrew/bin/bash",
] {
assert_eq!(flavor(Path::new(posix)), Flavor::Posix, "{posix}");
}
assert_eq!(flavor(Path::new("/bin/tcsh")), Flavor::Capture(&[&["-ic"]]));
assert_eq!(flavor(Path::new("/bin/csh")), Flavor::Capture(&[&["-ic"]]));
for other in [
"/opt/homebrew/bin/fish",
"/usr/local/bin/nu",
"/opt/homebrew/bin/xonsh",
"/usr/local/bin/pwsh",
] {
assert_eq!(
flavor(Path::new(other)),
Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
"{other}"
);
}
}
/// The login shell's part avoids everything only POSIX shells read.
#[test]
fn capture_command_is_shell_neutral() {
let _serial = serial();
let cmd = capture_command();
for bad in ["then", "fi", "$", "\\", "!", "\"", "&&"] {
assert!(!cmd.contains(bad), "{bad} in {cmd}");
}
assert_eq!(cmd.matches('\'').count(), 2);
assert!(cmd.starts_with("/bin/sh -c '"));
// /bin/sh, as the capture runs it, prints a parsable environment.
let out = Command::new("/bin/sh")
.arg("-c")
.arg(&cmd)
.env("CODEMAN_DESKTOP_PARSER_PROBE", "two\nlines")
.output()
.unwrap();
let env = parse_env(&String::from_utf8_lossy(&out.stdout)).expect("markers");
assert!(env
.iter()
.any(|(k, v)| k == "CODEMAN_DESKTOP_PARSER_PROBE" && v == "two\nlines"));
assert!(env.iter().any(|(k, _)| k == "PATH"));
}
/// A scratch directory with a fake `codeman` in `bin/` that prints its
/// arguments, the profile's marker variable and the names of everything in
/// its environment.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("codeman-desktop-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::create_dir_all(dir.join("home")).unwrap();
let scratch = Scratch(dir);
scratch.exe(
"bin/codeman",
"#!/bin/sh\nprintf '[%s]' \"$@\"; echo\necho \"profile: $CODEMAN_DESKTOP_TEST\"\n\
printf 'names:'; env | cut -d= -f1 | tr '\\n' ' '; echo\n",
);
scratch
}
/// Write an executable at `rel`, with `DIR` in `body` replaced by the scratch path.
fn exe(&self, rel: &str, body: &str) -> PathBuf {
let path = self.0.join(rel);
std::fs::write(&path, body.replace("DIR", &self.0.display().to_string())).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
path
}
fn argv_log(&self) -> String {
std::fs::read_to_string(self.0.join("argv.log")).unwrap_or_default()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
const HOSTILE_ARGS: [&str; 3] = ["web", "--base-url", "/x; touch /tmp/pwned"];
fn hostile_args() -> Vec<String> {
HOSTILE_ARGS.iter().map(|s| s.to_string()).collect()
}
/// A stand-in for fish: it refuses POSIX `then` and `$@` the way fish
/// does, logs its flags, prints profile noise around whatever it runs, and
/// its "profile" replaces the environment. On its interactive form only
/// when `INTERACTIVE_ONLY` is yes, PATH reaches the fake `codeman`.
const FAKE_FISH: &str = r#"#!/bin/sh
args=""
for a in "$@"; do
case "$a" in
*then*|*'$@'*) echo "fish: Unknown command: then" >&2; exit 127;;
-*) args="$args$a ";;
esac
cmd="$a"
done
echo "$args" >> "DIR/argv.log"
bin="DIR/bin"
case "INTERACTIVE_ONLY:$args" in yes:*-i*|no:*) ;; *) bin=/nonexistent;; esac
echo "Welcome to fish, the friendly interactive shell"
/usr/bin/env -i PATH="$bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST="from the profile" /bin/sh -c "$cmd"
echo "bye from the profile"
"#;
/// SCRIPT never reaches the non-POSIX shell: it runs under /bin/sh with the
/// environment the shell printed, and with nothing else.
#[test]
fn non_posix_shell_only_reports_its_environment() {
let _serial = serial();
let s = Scratch::new("fish");
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
let out = start_with(&fish, &hostile_args()).expect("started");
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "[web][--base-url][/x; touch /tmp/pwned]");
assert_eq!(lines[1], "profile: from the profile");
assert_eq!(s.argv_log(), "-l -c \n");
// env_clear: a variable this process has, but the profile did not
// export, must not reach codeman.
let names: Vec<&str> = lines[2].trim_start_matches("names:").split_whitespace().collect();
assert!(names.contains(&"PATH") && names.contains(&"CODEMAN_DESKTOP_TEST"));
let captured = ["PATH", "HOME", "CODEMAN_DESKTOP_TEST", "PWD", "OLDPWD", "SHLVL", "_"];
let leaked: Vec<String> = std::env::vars_os()
.filter_map(|(k, _)| k.into_string().ok())
.filter(|k| !captured.contains(&k.as_str()) && !k.starts_with("__CF"))
.filter(|k| names.contains(&k.as_str()))
.collect();
assert!(leaked.is_empty(), "leaked from the app: {leaked:?}");
}
/// No codeman on the login PATH (exit 127): the environment is captured
/// again from the interactive profile, and that run finds it.
#[test]
fn non_posix_shell_retries_with_the_interactive_profile() {
let _serial = serial();
let s = Scratch::new("fish-retry");
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "yes"));
let out = start_with(&fish, &hostile_args()).expect("started");
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
assert_eq!(s.argv_log(), "-l -c \n-l -i -c \n");
}
/// tcsh takes -l only as its only flag, so it is asked with -ic alone.
#[test]
fn tcsh_gets_ic_and_never_l() {
let _serial = serial();
let s = Scratch::new("tcsh");
let tcsh = s.exe(
"tcsh",
r#"#!/bin/sh
echo "$*" >> "DIR/argv.log"
if [ "$#" != 2 ] || [ "$1" != -ic ]; then echo "tcsh: Unknown option: $1" >&2; exit 1; fi
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=tcsh /bin/sh -c "$2"
"#,
);
let out = start_with(&tcsh, &hostile_args()).expect("started");
assert!(out.contains("profile: tcsh"), "{out}");
assert_eq!(s.argv_log().lines().count(), 1);
assert!(s.argv_log().starts_with("-ic /bin/sh -c '"));
}
/// A shell that takes neither -l nor -i still gets a bare -c.
#[test]
fn shell_without_login_flags_falls_back_to_plain_c() {
let _serial = serial();
let s = Scratch::new("nolflag");
let shell = s.exe(
"elvish",
r#"#!/bin/sh
echo "$1" >> "DIR/argv.log"
case "$1" in -l|-i) echo "flag provided but not defined: $1" >&2; exit 2;; esac
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=plain /bin/sh -c "$2"
"#,
);
let out = start_with(&shell, &hostile_args()).expect("started");
assert!(out.contains("profile: plain"), "{out}");
assert_eq!(s.argv_log(), "-l\n-l\n-c\n");
}
/// A shell that never prints its environment is reported by name with what
/// it said on stderr, never with stdout (which can carry secrets).
#[test]
fn capture_failure_names_the_shell() {
let _serial = serial();
let s = Scratch::new("broken");
let shell = s.exe(
"weirdsh",
"#!/bin/sh\necho 'API_KEY=sk-secret'\necho 'weirdsh: cannot start' >&2\nexit 3\n",
);
let err = start_with(&shell, &hostile_args()).unwrap_err();
assert!(err.contains("Could not read the environment"), "{err}");
assert!(err.contains("weirdsh: cannot start"), "{err}");
assert!(!err.contains("sk-secret"), "{err}");
}
/// POSIX shells keep running SCRIPT themselves: `-l -c SCRIPT codeman-desktop
/// args`, then `-l -i -c` when the login PATH had no codeman.
#[test]
fn posix_shell_runs_script_itself_and_retries_interactive() {
let _serial = serial();
let s = Scratch::new("zsh");
let zsh = s.exe(
"zsh",
r#"#!/bin/sh
[ "$1" = -l ] || exit 90
shift
PATH=/usr/bin:/bin
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
[ "$1" = -c ] || exit 91
shift
echo "$2" >> "DIR/argv.log"
HOME="DIR/home"
export PATH HOME
exec /bin/sh -c "$@"
"#,
);
let out = start_with(&zsh, &hostile_args()).expect("started");
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
// $0 of the script, once per run: login, then interactive.
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\n");
}
/// The installed check asks the same shells the same way and runs nothing:
/// found on the interactive PATH here, after the login PATH had none.
#[test]
fn installed_check_finds_codeman_without_running_it() {
let _serial = serial();
let s = Scratch::new("find-zsh");
let zsh = s.exe(
"zsh",
r#"#!/bin/sh
[ "$1" = -l ] || exit 90
shift
PATH=/usr/bin:/bin
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
[ "$1" = -c ] || exit 91
shift
echo "$2" >> "DIR/argv.log"
HOME="DIR/home"
export PATH HOME
exec /bin/sh -c "$@"
"#,
);
// A codeman that would leave a mark if it ran.
s.exe("bin/codeman", "#!/bin/sh\ntouch DIR/ran\n");
// Without the interactive profile: one login-shell look, not found.
assert!(!installed_with(&zsh, false));
assert_eq!(s.argv_log(), "codeman-desktop\n");
assert!(installed_with(&zsh, true));
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\ncodeman-desktop\n");
assert!(!s.0.join("ran").exists(), "the installed check ran codeman");
}
/// Nothing on any PATH and no ~/.codeman/app: not installed. Through a
/// non-POSIX shell too, which only reports its environment.
#[test]
fn installed_check_reports_missing_codeman() {
let _serial = serial();
let s = Scratch::new("find-fish");
std::fs::remove_file(s.0.join("bin/codeman")).unwrap();
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
assert!(!installed_with(&fish, true));
// The login-only look asks fish once, with -l and never -i.
let before = s.argv_log();
assert!(!installed_with(&fish, false));
assert_eq!(&s.argv_log()[before.len()..], "-l -c \n");
// The install.sh clone counts once node is there.
let app = s.0.join("home/.codeman/app/dist");
std::fs::create_dir_all(&app).unwrap();
std::fs::write(app.join("index.js"), "").unwrap();
s.exe("bin/node", "#!/bin/sh\nexit 0\n");
assert!(installed_with(&fish, true));
assert!(installed_with(&fish, false));
}
}
+256
View File
@@ -0,0 +1,256 @@
//! The login a connect goes out with, and what the Keychain keeps once the
//! server has answered it.
use crate::credentials::{self, Keychain};
use crate::probe::ProbeOutcome;
/// The login a connect goes out with, and where its password came from. A
/// typed password wins, then the one the Keychain holds for this user. Last
/// comes the login of the Codeman on this Mac, which keeps it where its own
/// clients find it (setup::local_login: its launchd job, then
/// ~/.codeman/.env) and only answers it at its own loopback address. That one
/// is used only when the config expected no password in the Keychain
/// (`expect_saved`: one that should be there and is not is reported, not
/// papered over), only for the username in use when there is one, and it is
/// never saved anywhere. `local` is asked only then: it runs processes.
pub fn choose_login(
user: Option<String>,
typed_password: Option<String>,
saved: Option<String>,
expect_saved: bool,
local: impl FnOnce() -> Option<(String, String)>,
) -> (Option<String>, Option<String>, PasswordSource) {
if let Some(password) = typed_password {
return (user, Some(password), PasswordSource::Typed);
}
if let Some(password) = saved {
return (user, Some(password), PasswordSource::Keychain);
}
if !expect_saved {
if let Some((local_user, password)) = local() {
if user.as_deref().is_none_or(|current| current == local_user) {
return (Some(local_user), Some(password), PasswordSource::LocalServer);
}
}
}
(user, None, PasswordSource::Absent)
}
/// A login the probe just tried, and what the user asked for it.
pub struct ProbedLogin<'a> {
/// The server, as the config and the Keychain accounts key it.
pub server: &'a str,
/// The username the config named for this server before the connect.
pub previous_user: Option<&'a str>,
pub username: Option<&'a str>,
pub password: Option<&'a str>,
/// Where the password came from.
pub source: PasswordSource,
pub remember: bool,
}
/// Where a connect's password came from, which decides what the Keychain
/// does with it (update_keychain).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordSource {
/// Typed on the connect page this time: saved if Remember is on.
Typed,
/// From the Keychain: there already.
Keychain,
/// The local Codeman's own login (setup::local_login): it stays where it
/// is, and the Keychain never gets a copy.
LocalServer,
/// No password.
Absent,
}
/// What update_keychain did: whether the Keychain now holds the login's
/// password, and whose items it deleted.
#[derive(Default)]
pub struct KeychainUpdate {
pub saved: bool,
pub deleted: Vec<String>,
}
/// Bring the Keychain in line with a probe the server answered, on the probe
/// worker. Only an accepted login changes anything (Ok, or a password the
/// server wants changed), since only then does the config get the new entry.
///
/// When that entry names another user than the config did (compared exactly,
/// as the account string is), the old user's item is deleted FIRST, before any
/// save: the Keychain worker runs jobs in order, so a Keychain that treats two
/// spellings of a name as one item cannot lose the new password to that
/// delete. Then the typed password is saved when Remember is on and deleted
/// when it is off, and a password the server wants changed is never kept.
pub fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate {
let mut update = KeychainUpdate::default();
if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) {
return update;
}
let mut delete = |user: &str| {
keychain.delete(&credentials::account(login.server, user));
update.deleted.push(user.to_string());
};
if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) {
delete(old);
}
let saved = match (outcome, login.username, login.password) {
(ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => {
if login.remember {
keychain
.save_blocking(&credentials::account(login.server, user), password)
.is_ok()
} else {
delete(user);
false
}
}
// A password that came from the Keychain is saved there already.
(ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain,
(ProbeOutcome::PasswordChangeRequired, Some(user), _) => {
delete(user);
false
}
_ => false,
};
update.saved = saved;
update
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::memory;
fn none() -> Option<(String, String)> {
panic!("the local login must not be looked up here");
}
#[test]
fn the_login_a_connect_uses() {
let s = |v: &str| Some(v.to_string());
let local = || Some(("admin".to_string(), "from-this-mac".to_string()));
// A typed password wins over everything, and nothing else is asked.
assert_eq!(
choose_login(s("kay"), s("typed"), s("kept"), true, none),
(s("kay"), s("typed"), PasswordSource::Typed)
);
// Then the Keychain's.
assert_eq!(
choose_login(s("kay"), None, s("kept"), true, none),
(s("kay"), s("kept"), PasswordSource::Keychain)
);
// The Keychain should hold one and does not: reported as absent, the
// local login is not tried in its place.
assert_eq!(
choose_login(s("kay"), None, None, true, none),
(s("kay"), None, PasswordSource::Absent)
);
// Nothing typed, nothing expected: the Codeman on this Mac's own
// login, for no username or the same one.
assert_eq!(
choose_login(None, None, None, false, local),
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
);
assert_eq!(
choose_login(s("admin"), None, None, false, local),
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
);
// Never for another user: the names are compared exactly.
assert_eq!(
choose_login(s("kay"), None, None, false, local),
(s("kay"), None, PasswordSource::Absent)
);
assert_eq!(
choose_login(s("Admin"), None, None, false, local),
(s("Admin"), None, PasswordSource::Absent)
);
// No local login either.
assert_eq!(
choose_login(None, None, None, false, || None),
(None, None, PasswordSource::Absent)
);
}
/// update_keychain for one probe answer; the log is what the Keychain did,
/// in order (the last load only waits for the queue to drain).
fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec<String>) {
let (keychain, store) = memory::keychain();
let update = update_keychain(&keychain, &outcome, &login);
keychain.load_blocking("done");
let mut log = store.log();
assert_eq!(log.pop().as_deref(), Some("load done"));
(update, log)
}
fn login<'a>(
previous_user: Option<&'a str>,
username: Option<&'a str>,
source: PasswordSource,
remember: bool,
) -> ProbedLogin<'a> {
ProbedLogin {
server: "http://a/",
previous_user,
username,
password: (source != PasswordSource::Absent).then_some("secret"),
source,
remember,
}
}
#[test]
fn what_the_keychain_keeps_after_a_connect() {
let ok = || ProbeOutcome::Ok {
version: "1.48.0".into(),
};
let item = |user: &str| credentials::account("http://a/", user);
// A typed password is saved when Remember is on, deleted when it is off.
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true));
assert!(update.saved && update.deleted.is_empty());
assert_eq!(log, [format!("save {}", item("kay"))]);
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false));
assert!(!update.saved);
assert_eq!(update.deleted, ["kay"]);
assert_eq!(log, [format!("delete {}", item("kay"))]);
// One that came from the Keychain is there already: nothing is written.
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true));
assert!(update.saved && log.is_empty());
// The local Codeman's own login never gets a copy, Remember or not.
let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true));
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
// Another user than before: the old user's item goes FIRST, then the save.
let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true));
assert!(update.saved);
assert_eq!(update.deleted, ["old"]);
assert_eq!(
log,
[format!("delete {}", item("old")), format!("save {}", item("new"))]
);
// A password the server wants changed is never kept.
let (update, log) = keychain_after(
ProbeOutcome::PasswordChangeRequired,
login(Some("kay"), Some("kay"), PasswordSource::Typed, true),
);
assert!(!update.saved);
assert_eq!(update.deleted, ["kay"]);
assert_eq!(log, [format!("delete {}", item("kay"))]);
// A login the server did not accept changes nothing.
for refused in [
ProbeOutcome::BadCredentials,
ProbeOutcome::NeedsLogin,
ProbeOutcome::Refused,
ProbeOutcome::Timeout,
] {
let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true));
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+161
View File
@@ -0,0 +1,161 @@
//! The menu bar.
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::config::Config;
use crate::logo::{self, BlinkStyle, Signal};
use crate::{APP_NAME, VERSION};
/// Codeman's repository and its documentation (the Help menu, About).
pub const REPO_URL: &str = "https://github.com/Ark0N/Codeman";
pub const DOCS_URL: &str = "https://github.com/Ark0N/Codeman/wiki";
pub 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";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
}
pub struct AppMenu {
pub bar: Menu,
pub window_menu: Submenu,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
}
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
let bar = Menu::new();
let about = PredefinedMenuItem::about(
Some("About Codeman"),
Some(AboutMetadata {
name: Some(APP_NAME.into()),
version: Some(VERSION.into()),
comments: Some("A native window for your Codeman server.".into()),
website: Some(REPO_URL.into()),
..Default::default()
}),
);
let switch = MenuItem::with_id(
menu_id::SWITCH,
"Switch Server\u{2026}",
true,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)),
);
let app_menu = Submenu::with_items(
APP_NAME,
true,
&[
&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(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
],
)?;
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
let edit = Submenu::with_items(
"Edit",
true,
&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
],
)?;
let start_fullscreen = CheckMenuItem::with_id(
menu_id::START_FULLSCREEN,
"Always Open in Full Screen",
true,
config.start_fullscreen,
None,
);
let view = Submenu::with_items(
"View",
true,
&[
&MenuItem::with_id(
menu_id::RELOAD,
"Reload",
true,
Some(Accelerator::new(Modifiers::META, Code::KeyR)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::fullscreen(None),
&start_fullscreen,
&PredefinedMenuItem::separator(),
&MenuItem::with_id(
menu_id::DEVTOOLS,
"Developer Tools",
true,
Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)),
),
],
)?;
// 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,
&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
],
)?;
let help_menu = Submenu::with_items(
"Help",
true,
&[
&MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None),
&MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None),
],
)?;
bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?;
Ok(AppMenu {
bar,
window_menu,
help_menu,
start_fullscreen,
logo_styles,
})
}
File diff suppressed because it is too large. Load diff
+787
View File
@@ -0,0 +1,787 @@
//! "Is a Codeman server answering at this address, and does it want a login?"
//!
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
//! The answer is classified into something the connect page can explain. It is
//! asked on a user action, or by one of the This Mac tab's waits (for an
//! install to bring Codeman up, for a server that is starting), which end at
//! the first answer, never as a standing poll: Codeman counts a request without
//! valid credentials against the login rate limit (10 per IP).
use serde::Serialize;
use std::time::Duration;
use url::Url;
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";
/// rustls' text for `Error::InvalidCertificate`, which is what a handshake
/// with a certificate the OS does not trust fails with. ureq hands it over as
/// an `io::Error` of kind `InvalidData`, so the text is all there is to match
/// without a direct rustls dependency.
const CERT_REJECTED_PREFIX: &str = "invalid peer certificate";
/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which
/// cannot be matched on stable Rust, so the prefix is the only handle. The
/// prefix is the same on macOS and Linux; only the resolver's reason differs.
const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProbeOutcome {
Ok {
version: String,
},
/// 401 and we sent no credentials.
NeedsLogin,
/// 401 although we sent credentials.
BadCredentials,
/// 429: too many failed logins from this address. A single-user Codeman
/// still accepts the right password, so the page needs to know whether
/// this probe carried one. `retry_after_secs` is the server's
/// `Retry-After`, when it gave one in seconds.
#[serde(rename_all = "camelCase")]
RateLimited {
sent_credentials: bool,
#[serde(skip_serializing_if = "Option::is_none")]
retry_after_secs: Option<u64>,
},
/// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a
/// multi-user Codeman wants this account's password changed first. Page
/// loads are exempt, so the web UI can show its change-password dialog.
PasswordChangeRequired,
/// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain).
HostNotAllowed,
/// 3xx. `location` is the target resolved against the probed URL.
/// `suggested` is a server address to use instead, set only when the
/// redirect stays on the same host, only moves to https or another port,
/// and still points at the status API. Never followed: a second request
/// would carry the credentials to an address the user did not type.
Redirect {
location: String,
suggested: Option<String>,
},
/// Something answered, but not Codeman's status API.
NotCodeman {
status: u16,
},
Refused,
/// No route to the address: the machine is off or on another network,
/// or macOS's Local Network privacy setting blocked the app.
Unreachable,
/// An http:// probe whose connection was closed without an answer. A
/// Codeman started with `--https` does exactly that to plain HTTP.
MaybeHttps,
Timeout,
TlsUntrusted {
detail: String,
},
Unresolvable,
Failed {
detail: String,
},
}
impl ProbeOutcome {
/// The server's version, when it answered as Codeman.
pub fn version(&self) -> Option<String> {
match self {
ProbeOutcome::Ok { version } => Some(version.clone()),
_ => None,
}
}
}
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
/// was asked, `location` and `retry_after` are those response headers.
pub fn classify_response(
request: &Url,
status: u16,
location: Option<&str>,
retry_after: Option<&str>,
sent_credentials: bool,
body: &str,
) -> ProbeOutcome {
match status {
200 => {
let version = serde_json::from_str::<serde_json::Value>(body)
.ok()
.filter(|v| v["success"] == true)
.and_then(|v| v["data"]["version"].as_str().map(str::to_string));
match version {
Some(version) => ProbeOutcome::Ok { version },
None => ProbeOutcome::NotCodeman { status },
}
}
300..=399 => {
// A 3xx without a usable Location (304, a garbled header) is not a
// redirect anyone can act on.
match location.and_then(|l| request.join(l.trim()).ok()) {
Some(target) => ProbeOutcome::Redirect {
suggested: suggested_server(request, &target),
location: target.into(),
},
None => ProbeOutcome::NotCodeman { status },
}
}
401 if sent_credentials => ProbeOutcome::BadCredentials,
401 => ProbeOutcome::NeedsLogin,
403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed,
// Codeman only says this after the Basic credentials verified (the
// probe sends no cookie), so without credentials it is not Codeman's
// answer to us, and loading the page without them would not help.
403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => {
ProbeOutcome::PasswordChangeRequired
}
429 => ProbeOutcome::RateLimited {
sent_credentials,
retry_after_secs: retry_after.and_then(|v| v.trim().parse::<u64>().ok()),
},
other => ProbeOutcome::NotCodeman { status: other },
}
}
/// `errorCode` of a Codeman `ApiResponse` error body.
fn error_code(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
Some(parsed.get("errorCode")?.as_str()?.to_string())
}
/// The server base URL a redirect points to, when it is the same server
/// moved to https or another port (a reverse proxy upgrading plain HTTP, the
/// usual case). Anything else, such as another host (a login gateway the
/// window could not pass anyway) or a downgrade to http, gets no suggestion.
fn suggested_server(request: &Url, target: &Url) -> Option<String> {
let upgrade = request.scheme() == "http" && target.scheme() == "https";
let port_change =
request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default();
if !(upgrade || port_change) || target.host() != request.host() {
return None;
}
if !target.username().is_empty() || target.password().is_some() {
return None;
}
// The redirect must keep pointing at the status API (a proxy may add a
// base path in front of it); the base is what comes before it.
if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) {
return None;
}
let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()];
let mut base = target.clone();
base.set_path(base_path);
base.set_query(None);
base.set_fragment(None);
Some(base.into())
}
/// Transport failures, reduced to what the connect page explains differently.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
Refused,
Unreachable,
ClosedWithoutAnswer,
Timeout,
Tls(String),
Dns,
Other(String),
}
pub fn classify_transport(err: TransportError) -> ProbeOutcome {
match err {
TransportError::Refused => ProbeOutcome::Refused,
TransportError::Unreachable => ProbeOutcome::Unreachable,
TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps,
TransportError::Timeout => ProbeOutcome::Timeout,
TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail },
TransportError::Dns => ProbeOutcome::Unresolvable,
TransportError::Other(detail) => ProbeOutcome::Failed { detail },
}
}
/// `plain_http`: the probe went to an http:// URL. Only there does a closed
/// connection hint at an https-only server; over https the same close is a
/// failed handshake with nothing more to say about it.
fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
use std::io::ErrorKind;
match err {
ureq::Error::Timeout(_) => TransportError::Timeout,
ureq::Error::HostNotFound => TransportError::Dns,
ureq::Error::ConnectionFailed => TransportError::Refused,
// ureq passes both a rejected certificate (rustls wraps it as
// InvalidData during the handshake) and a failed name lookup (std's
// own error) through as plain io errors, so they are told apart here
// before the kinds below swallow them.
ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => {
TransportError::Tls(e.to_string())
}
ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => {
TransportError::Dns
}
ureq::Error::Io(e) => match e.kind() {
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => {
TransportError::Refused
}
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable,
ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer,
ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout,
_ => TransportError::Other(e.to_string()),
},
// ureq's own rustls errors come from setting up the TLS client (the
// handshake's arrive as Io above); only a certificate rejection is
// "untrusted", anything else is reported as it is.
ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()),
other => TransportError::Other(other.to_string()),
}
}
/// Ask the server. Blocking: call it from a worker thread.
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
probe_within(server, authorization, TIMEOUT)
}
/// 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();
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.
.max_redirects(0)
.tls_config(tls)
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
.build()
.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);
}
match req.call() {
Ok(mut resp) => {
let status = resp.status().as_u16();
let header = |name: &str| {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let location = header("location");
let retry_after = header("retry-after");
let body = match resp.body_mut().with_config().limit(MAX_BODY).read_to_string() {
Ok(body) => body,
// The answer began but did not finish in time. Where the body
// decides what answered (200, 403), that is a timeout, not
// "something else answers here".
Err(e) if matches!(status, 200 | 403) && transport_error(&e, false) == TransportError::Timeout => {
return ProbeOutcome::Timeout;
}
Err(_) => String::new(),
};
classify_response(
&status_url,
status,
location.as_deref(),
retry_after.as_deref(),
authorization.is_some(),
&body,
)
}
Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
fn status_url() -> Url {
Url::parse("http://studio.lan:3000/api/status").unwrap()
}
/// `classify_response` for the cases that need no headers.
fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome {
classify_response(&status_url(), status, None, None, sent_credentials, body)
}
fn redirect(request: &str, location: &str) -> ProbeOutcome {
classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "")
}
fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome {
let err = ureq::Error::Io(io::Error::new(kind, message.to_string()));
classify_transport(transport_error(&err, plain_http))
}
#[test]
fn ok_needs_success_and_version() {
assert_eq!(
classify(
200,
false,
r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"#
),
ProbeOutcome::Ok {
version: "1.47.0".into()
}
);
assert_eq!(
classify(200, false, "<html>hello</html>"),
ProbeOutcome::NotCodeman { status: 200 }
);
assert_eq!(
classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#),
ProbeOutcome::NotCodeman { status: 200 }
);
assert_eq!(
classify(200, false, r#"{"success":true}"#),
ProbeOutcome::NotCodeman { status: 200 }
);
}
#[test]
fn auth_and_guards() {
assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin);
assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials);
assert_eq!(
classify(403, false, "Forbidden: host not allowed"),
ProbeOutcome::HostNotAllowed
);
// The Host guard runs before authentication, so it wins even with credentials.
assert_eq!(
classify(403, true, "Forbidden: host not allowed"),
ProbeOutcome::HostNotAllowed
);
assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 });
assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 });
}
/// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`.
#[test]
fn password_change_required_only_after_sent_credentials() {
let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#;
assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired);
assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 });
// Another Codeman error code, or the text without the JSON field, is not it.
assert_eq!(
classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#),
ProbeOutcome::NotCodeman { status: 403 }
);
assert_eq!(
classify(403, true, "PASSWORD_CHANGE_REQUIRED"),
ProbeOutcome::NotCodeman { status: 403 }
);
}
#[test]
fn rate_limited_says_whether_credentials_went_along() {
let body = "Too Many Requests, try again later";
assert_eq!(
classify(429, false, body),
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: None
}
);
assert_eq!(
classify(429, true, body),
ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: None
}
);
// auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed.
assert_eq!(
classify_response(&status_url(), 429, None, Some(" 742 "), true, body),
ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: Some(742)
}
);
assert_eq!(
classify_response(
&status_url(),
429,
None,
Some("Wed, 21 Oct 2026 07:28:00 GMT"),
false,
body
),
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: None
}
);
}
#[test]
fn redirect_to_https_on_the_same_host_is_suggested() {
assert_eq!(
redirect("http://studio.lan/api/status", "https://studio.lan/api/status"),
ProbeOutcome::Redirect {
location: "https://studio.lan/api/status".into(),
suggested: Some("https://studio.lan/".into())
}
);
// A base path the proxy adds stays part of the suggestion; a query does not.
assert_eq!(
redirect(
"http://studio.lan:3000/api/status",
"https://studio.lan/codeman/api/status?from=http"
),
ProbeOutcome::Redirect {
location: "https://studio.lan/codeman/api/status?from=http".into(),
suggested: Some("https://studio.lan/codeman/".into())
}
);
// A relative Location resolves against the probed URL; another port is fine.
assert_eq!(
redirect(
"http://studio.lan:3000/base/api/status",
"//studio.lan:3001/base/api/status"
),
ProbeOutcome::Redirect {
location: "http://studio.lan:3001/base/api/status".into(),
suggested: Some("http://studio.lan:3001/base/".into())
}
);
}
#[test]
fn other_redirects_are_reported_without_a_suggestion() {
// Another host (a login gateway, say).
assert_eq!(
redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"),
ProbeOutcome::Redirect {
location: "https://sso.example.com/login?next=x".into(),
suggested: None
}
);
// Same host, but no longer the status API.
assert_eq!(
redirect("http://studio.lan/api/status", "/login"),
ProbeOutcome::Redirect {
location: "http://studio.lan/login".into(),
suggested: None
}
);
// A downgrade to plain http.
assert_eq!(
redirect("https://studio.lan/api/status", "http://studio.lan/api/status"),
ProbeOutcome::Redirect {
location: "http://studio.lan/api/status".into(),
suggested: None
}
);
// Same scheme and port, only the path moved.
assert_eq!(
redirect("http://studio.lan/api/status", "/x/api/status"),
ProbeOutcome::Redirect {
location: "http://studio.lan/x/api/status".into(),
suggested: None
}
);
// Credentials in the target are never suggested.
assert_eq!(
redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"),
ProbeOutcome::Redirect {
location: "https://u:p@studio.lan/api/status".into(),
suggested: None
}
);
// No Location (304), or one that does not parse: nothing to act on.
assert_eq!(
classify_response(&status_url(), 304, None, None, false, ""),
ProbeOutcome::NotCodeman { status: 304 }
);
assert_eq!(
classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""),
ProbeOutcome::NotCodeman { status: 302 }
);
}
#[test]
fn transport_classes() {
assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused);
assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout);
assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable);
assert_eq!(
classify_transport(TransportError::Tls("bad cert".into())),
ProbeOutcome::TlsUntrusted {
detail: "bad cert".into()
}
);
}
/// The io errors exactly as ureq hands them over (`Error::Io`).
#[test]
fn io_errors_are_told_apart() {
use io::ErrorKind;
// rustls' certificate rejection from the handshake.
let cert = "invalid peer certificate: UnknownIssuer";
assert_eq!(
io_outcome(ErrorKind::InvalidData, cert, false),
ProbeOutcome::TlsUntrusted { detail: cert.into() }
);
// Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem.
assert_eq!(
io_outcome(
ErrorKind::InvalidData,
"received corrupt message of type InvalidContentType",
false
),
ProbeOutcome::Failed {
detail: "received corrupt message of type InvalidContentType".into()
}
);
// std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable).
assert_eq!(
io_outcome(
ErrorKind::Other,
"failed to lookup address information: nodename nor servname provided, or not known",
true
),
ProbeOutcome::Unresolvable
);
// The same text from the OS (an errno) is not a lookup failure.
let os = ureq::Error::Io(io::Error::from_raw_os_error(1));
assert!(!matches!(
classify_transport(transport_error(&os, true)),
ProbeOutcome::Unresolvable
));
// ureq's "Peer disconnected": over http a hint at https, over https just a failure.
assert_eq!(
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true),
ProbeOutcome::MaybeHttps
);
assert_eq!(
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false),
ProbeOutcome::Failed {
detail: "Peer disconnected".into()
}
);
// No route: Local Network privacy, or a machine that is off.
assert_eq!(
io_outcome(ErrorKind::HostUnreachable, "No route to host", true),
ProbeOutcome::Unreachable
);
assert_eq!(
io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false),
ProbeOutcome::Unreachable
);
assert_eq!(
io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true),
ProbeOutcome::Refused
);
assert_eq!(
classify_transport(transport_error(&ureq::Error::HostNotFound, true)),
ProbeOutcome::Unresolvable
);
// A ureq TLS setup message is not an untrusted certificate.
assert_eq!(
classify_transport(transport_error(
&ureq::Error::Tls("Rustls invalid dns name error"),
false
)),
ProbeOutcome::Failed {
detail: "Rustls invalid dns name error".into()
}
);
}
#[test]
fn outcome_serializes_for_the_page() {
let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap();
assert_eq!(
json(ProbeOutcome::NotCodeman { status: 404 }),
serde_json::json!({"kind":"notCodeman","status":404})
);
assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"}));
assert_eq!(
json(ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: None
}),
serde_json::json!({"kind":"rateLimited","sentCredentials":true})
);
assert_eq!(
json(ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: Some(60)
}),
serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60})
);
assert_eq!(
json(ProbeOutcome::PasswordChangeRequired),
serde_json::json!({"kind":"passwordChangeRequired"})
);
assert_eq!(
json(ProbeOutcome::Redirect {
location: "https://studio.lan/api/status".into(),
suggested: Some("https://studio.lan/".into())
}),
serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"})
);
assert_eq!(
json(ProbeOutcome::Redirect {
location: "https://sso.example.com/".into(),
suggested: None
}),
serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null})
);
assert_eq!(
json(ProbeOutcome::Unreachable),
serde_json::json!({"kind":"unreachable"})
);
assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"}));
assert_eq!(
json(ProbeOutcome::TlsUntrusted { detail: "x".into() }),
serde_json::json!({"kind":"tlsUntrusted","detail":"x"})
);
assert_eq!(
json(ProbeOutcome::Unresolvable),
serde_json::json!({"kind":"unresolvable"})
);
}
/// Against real sockets: a closed port is `Refused`, a listener that
/// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`,
/// a 401 is `NeedsLogin`, a redirect is reported without being followed,
/// and a listener that hangs up without answering is `MaybeHttps`.
#[test]
fn probe_against_local_sockets() {
use std::io::{Read, Write};
use std::net::TcpListener;
let closed = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::Refused);
/// Accept one connection, read the request and send `response`
/// (nothing, then close, for an empty one).
fn serve_raw(response: String) -> u16 {
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() {
// Read the whole request head (a GET has no body) so that
// closing sends a FIN, not a reset for unread bytes.
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]),
}
}
let _ = s.write_all(response.as_bytes());
}
});
port
}
fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 {
serve_raw(format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
body.len()
))
}
let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#);
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(
probe(&url, None),
ProbeOutcome::Ok {
version: "9.9.9".into()
}
);
let p = serve_once("404 Not Found", "", "nope");
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 });
let p = serve_once("401 Unauthorized", "", "Unauthorized");
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin);
let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests");
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(
probe(&url, Some("Basic YWRtaW46eA==")),
ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: Some(30)
}
);
// Nothing listens on the redirect target: had it been followed, the
// answer would be Refused instead.
let p = serve_once(
"301 Moved Permanently",
&format!("Location: https://127.0.0.1:{closed}/api/status\r\n"),
"",
);
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(
probe(&url, None),
ProbeOutcome::Redirect {
location: format!("https://127.0.0.1:{closed}/api/status"),
suggested: Some(format!("https://127.0.0.1:{closed}/"))
}
);
let p = serve_raw(String::new());
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
}
/// A Codeman that sends its status headers and then stalls (a blocked
/// event loop, a lossy link) did not answer in time: a Timeout, not
/// NotCodeman ("something else on this port").
#[test]
fn a_body_that_stalls_is_a_timeout() {
use std::io::{Read, Write};
use std::net::TcpListener;
/// Send `head` and the start of a body, then hold the connection open.
fn serve_stalling(status_line: &'static str) -> u16 {
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 buf = [0u8; 4096];
let _ = s.read(&mut buf);
let _ = s.write_all(
format!("HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: 4096\r\n\r\n{{\"success\":true,").as_bytes(),
);
std::thread::sleep(Duration::from_secs(3));
}
});
port
}
let short = Duration::from_millis(400);
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("200 OK"))).unwrap();
assert_eq!(probe_within(&url, None, short), ProbeOutcome::Timeout);
// A 401 has said all it needs to with its status line.
let url = Url::parse(&format!("http://127.0.0.1:{}/", serve_stalling("401 Unauthorized"))).unwrap();
assert_eq!(probe_within(&url, None, short), ProbeOutcome::NeedsLogin);
}
}
File diff suppressed because it is too large. Load diff
+547
View File
@@ -0,0 +1,547 @@
//! The installs that run in Terminal: on this Mac, and on another machine over
//! SSH.
//!
//! The Terminal route is for what the in-app install (`gui_install.rs`) 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 only text from the user is the
//! SSH login, which `SshTarget::parse` limits to the characters of a user and
//! host name before it is quoted.
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::setup::{DEFAULT_PORT, INSTALLER_URL};
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
/// one is also how `inspect` recognizes an install that is still running.
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
/// `s` as one bash word.
pub fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// What the This Mac install asks `install.sh` for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalInstall {
pub port: u16,
/// Let the installer ask how other devices reach Codeman (Tailscale, the
/// local network with a password). Off: this Mac only (`--local`).
pub network: bool,
}
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
/// again at login, and the app finds it there (read_service). No `--yes`: the
/// user answers the installer's questions (Homebrew, AI coding tools).
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
let mut args = Vec::new();
if !opts.network {
args.push("--local".to_string());
}
args.push("--service".to_string());
args.push("--port".to_string());
args.push(opts.port.to_string());
args
}
/// The Terminal script for a This Mac install. It downloads the installer to a
/// temporary file and runs it from there, so the installer's input is the
/// terminal itself.
pub fn local_install_script(opts: LocalInstall) -> String {
let args = local_install_args(opts);
let shown = format!("bash install.sh {}", args.join(" "));
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
format!(
r#"#!/bin/bash
rm -f -- "$0"
printf '\033]0;%s\007' 'Install Codeman'
cat <<'CODEMAN_MSG'
Installing Codeman on this Mac.
The installer may ask for your Mac password (it installs Homebrew, Node.js and
tmux when they are missing) and which AI coding tools to set up. Codeman then
runs in the background and starts when you log in, so your sessions keep
running when the app is closed. The Codeman app opens it by itself when the
install is done.
CODEMAN_MSG
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
trap 'rm -f "$tmp"' EXIT
echo {from}
if ! curl -fsSL {url} -o "$tmp"; then
echo
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
exit 1
fi
echo {shown}
echo
bash "$tmp" {args}
status=$?
echo
if [ "$status" -eq 0 ]; then
echo 'Done. You can close this window.'
else
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
fi
exit "$status"
"#,
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
url = sh_quote(INSTALLER_URL),
shown = sh_quote(&format!("$ {shown}")),
args = quoted.join(" "),
)
}
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
}
const SSH_FORMAT: &str =
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
impl SshTarget {
/// Only the characters of a user and host name, never a leading `-` (ssh
/// would read it as an option) and never an IPv6 literal (ambiguous with
/// the port; a host name or an IPv4 address works).
pub fn parse(input: &str) -> Result<Self, String> {
let s = input.trim();
if s.is_empty() || s.len() > 300 {
return Err(SSH_FORMAT.into());
}
let (user, rest) = match s.split_once('@') {
Some((u, r)) => (Some(u), r),
None => (None, s),
};
let (host, port) = match rest.split_once(':') {
Some((h, p)) => {
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
})?;
(h, Some(port))
}
None => (rest, None),
};
let name_ok = |v: &str, max: usize| {
!v.is_empty()
&& v.len() <= max
&& !v.starts_with(['-', '.'])
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
};
if let Some(u) = user {
if !name_ok(u, 64) {
return Err(SSH_FORMAT.into());
}
}
if !name_ok(host, 253) {
return Err(SSH_FORMAT.into());
}
Ok(SshTarget {
user: user.map(str::to_string),
host: host.to_string(),
port,
})
}
/// `user@host`, or `host`.
pub fn destination(&self) -> String {
match &self.user {
Some(u) => format!("{u}@{}", self.host),
None => self.host.clone(),
}
}
/// The address the connect screen suggests once the install is done: the
/// installer's default port on that host. Tailscale and a sub-path give
/// another address, which the installer prints at the end.
pub fn suggested_url(&self) -> String {
format!("http://{}:{DEFAULT_PORT}", self.host)
}
}
/// What the remote login shell runs. It may be fish or tcsh, so the work is
/// one single-quoted `bash -c` word with nothing in it those shells treat
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
/// documented install and the installer asks its questions on ssh's terminal
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
/// plain pipe would report bash's success on an empty script. The installer
/// needs bash anyway.
pub fn remote_command() -> String {
format!(
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
u = INSTALLER_URL
)
}
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
/// installer's questions, the port, and `--` before the destination.
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
let mut args = vec!["-t".to_string()];
if let Some(port) = target.port {
args.push("-p".into());
args.push(port.to_string());
}
args.push("--".into());
args.push(target.destination());
args.push(remote_command());
args
}
/// The Terminal script for an install on another machine over SSH.
pub fn remote_install_script(target: &SshTarget) -> String {
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
let dest = target.destination();
format!(
r#"#!/bin/bash
rm -f -- "$0"
printf '\033]0;%s\007' {title}
echo {what}
cat <<'CODEMAN_MSG'
ssh may ask you to confirm the machine and for its password. Then the installer
asks how Codeman should be reached (Tailscale, the local network with a password,
or that machine only) and whether to run it in the background. At the end it
prints the address of Codeman there: connect to it in the Codeman app, under
Another machine.
CODEMAN_MSG
ssh {args}
status=$?
echo
if [ "$status" -eq 0 ]; then
echo 'Done. You can close this window.'
else
echo "ssh or the installer stopped (exit $status)."
fi
exit "$status"
"#,
title = sh_quote(&format!("Install Codeman on {}", target.host)),
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
args = args.join(" "),
)
}
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Run `script` in a new Terminal window. The script file is private to this
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
/// itself as it starts.
#[cfg(target_os = "macos")]
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
use std::os::unix::fs::OpenOptionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
let written = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o700)
.open(&path)
.and_then(|mut f| f.write_all(script.as_bytes()));
if let Err(e) = written {
let _ = std::fs::remove_file(&path);
return Err(format!("Could not write the install script: {e}"));
}
let out = Command::new("/usr/bin/open")
.args(["-b", "com.apple.Terminal"])
.arg(&path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let _ = std::fs::remove_file(&path);
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
Err(if said.is_empty() {
"Could not open Terminal.".into()
} else {
format!("Could not open Terminal: {said}")
})
}
Err(e) => {
let _ = std::fs::remove_file(&path);
Err(format!("Could not open Terminal: {e}"))
}
}
}
#[cfg(not(target_os = "macos"))]
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
let _ = &SCRIPT_COUNTER;
Err("Installing from the app is only available on macOS so far.".into())
}
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
/// context, so it cannot use the web clipboard API itself.
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
let mut child = Command::new("/usr/bin/pbcopy")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Could not copy: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Could not copy: {e}"))?;
}
match child.wait() {
Ok(s) if s.success() => Ok(()),
_ => Err("Could not copy the command.".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoting() {
assert_eq!(sh_quote("plain"), "'plain'");
assert_eq!(sh_quote("it's"), r"'it'\''s'");
assert_eq!(sh_quote(""), "''");
}
#[test]
fn local_install_flags() {
assert_eq!(
local_install_args(LocalInstall {
port: 3000,
network: false
}),
["--local", "--service", "--port", "3000"]
);
assert_eq!(
local_install_args(LocalInstall {
port: 3007,
network: true
}),
["--service", "--port", "3007"]
);
}
/// Run a script under bash with `curl` and `bash`... replaced, and report
/// what it did. The real installer never runs in a test.
#[cfg(unix)]
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
use std::os::unix::fs::PermissionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
for (name, body) in fakes {
let p = dir.join("bin").join(name);
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = dir.join("run.command");
std::fs::write(&file, script).unwrap();
let out = Command::new("/bin/bash")
.arg(&file)
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
.env("TMPDIR", &dir)
.stdin(Stdio::null())
.output()
.unwrap();
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
let self_deleted = !file.exists();
let _ = std::fs::remove_dir_all(&dir);
(log, out.status.code().unwrap_or(-1), self_deleted)
}
#[cfg(unix)]
#[test]
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
let script = local_install_script(LocalInstall {
port: 3001,
network: false,
});
let (log, code, self_deleted) = run_script(
&script,
&[(
"curl",
r#"#!/bin/sh
echo "curl $*" >> DIR/log
out=""
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
cat > "$out" <<'INSTALLER'
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
echo >> DIR/log
exit 7
INSTALLER
"#,
)],
);
assert!(self_deleted, "the script must delete itself");
assert!(
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
"{log}"
);
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
assert_eq!(code, 7, "the installer's exit status is the script's");
}
#[cfg(unix)]
#[test]
fn local_script_stops_when_the_download_fails() {
let script = local_install_script(LocalInstall {
port: 3000,
network: true,
});
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
assert_eq!(log, "curl\n");
assert_eq!(code, 1);
}
#[test]
fn ssh_targets() {
assert_eq!(
SshTarget::parse(" kay@build-box ").unwrap(),
SshTarget {
user: Some("kay".into()),
host: "build-box".into(),
port: None
}
);
assert_eq!(
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
SshTarget {
user: Some("pi".into()),
host: "192.168.1.20".into(),
port: Some(2222)
}
);
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
assert_eq!(
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
"a.b@tnode.tailnet.ts.net"
);
for bad in [
"",
"-oProxyCommand=touch /tmp/x",
"kay@-oProxyCommand=x",
"-l@host",
"kay@host name",
"kay@host;rm",
"kay@host$(x)",
"k'y@host",
"kay@",
"@host",
"kay@host:",
"kay@host:0",
"kay@host:x",
"kay@fe80::1",
"kay@[::1]",
"a@b@c",
".hidden",
] {
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
}
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
}
#[test]
fn ssh_arguments() {
let t = SshTarget::parse("kay@box:2222").unwrap();
let args = ssh_args(&t);
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
assert_eq!(args.len(), 6);
let remote = &args[5];
let head = "bash -o pipefail -c '";
assert!(remote.starts_with(head) && remote.ends_with('\''));
// One word for fish and tcsh: no quote inside, nothing they expand.
let inner = &remote[head.len()..remote.len() - 1];
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
}
#[cfg(unix)]
#[test]
fn remote_script_runs_ssh_with_exactly_those_arguments() {
let t = SshTarget::parse("kay@box:2222").unwrap();
let (log, code, self_deleted) = run_script(
&remote_install_script(&t),
&[(
"ssh",
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
)],
);
assert!(self_deleted);
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
assert_eq!(log, format!("{expected}\n"));
assert_eq!(code, 3);
}
/// The remote command does what it says under a POSIX shell: curl first,
/// wget without curl, and a clear error with neither.
#[cfg(unix)]
#[test]
fn remote_command_picks_a_downloader() {
use std::os::unix::fs::PermissionsExt;
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
let exe = |name: &str, body: &str| {
let p = dir.join("bin").join(name);
std::fs::write(&p, body).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
};
// The outer `bash -o pipefail` is the real one; the `bash` the
// download is piped into only reports what it was fed.
exe(
"bash",
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
);
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
let run = || {
let out = Command::new("/bin/sh")
.arg("-c")
.arg(remote_command())
.env("PATH", dir.join("bin"))
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
};
let (out, _, _) = run();
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
let (out, _, _) = run();
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
// A failed download fails the command (pipefail), not "Done".
exe(
"curl",
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
);
let (_, _, code) = run();
assert_ne!(code, Some(0));
std::fs::remove_file(dir.join("bin/curl")).unwrap();
std::fs::remove_file(dir.join("bin/wget")).unwrap();
let (out, err, code) = run();
assert_eq!(out, "");
assert!(err.contains("needs curl or wget"), "{err}");
assert_eq!(code, Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
}
+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.49.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
+145 -41
View File
@@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -197,6 +197,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -259,9 +270,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -277,12 +297,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -345,31 +376,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -384,11 +439,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -405,11 +465,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -425,6 +492,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -572,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -596,7 +667,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -683,10 +759,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -850,9 +926,25 @@ Three things to know before you spawn one:
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
on its welcome screen), you can hand work to that model from here. You stay the planner and
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
other mode: `spawn_workers plan review:deepseek summ:local`.
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
`spawn_workers plan review:deepseek summ:local`.
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
| Worker kind | Agent | Why you would pick it | Drive it with |
| --- | --- | --- | --- |
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
the spawn says so with the ones that are. Installing an agent is a person's click in the
**Run a local model** window, like installing a model: tell the user, do not work around it.
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
Reach for it when:
@@ -866,9 +958,9 @@ Reach for it when:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; starts a stopped runtime first; empty = failed
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
@@ -891,8 +983,9 @@ The limits, before you promise the user anything:
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
window. This skill never installs one: that is a download of many gigabytes the person
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
instead of guessing.
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
user that instead of guessing. It also lists the models on this Mac (`models`); only the
`active` one runs, and switching to another is a person's decision in the window.
- **One model, one request at a time.** The runtime loads one model and answers one request
at a time, so two local workers take turns rather than work side by side. Fan out to the
cloud; give the Mac a queue.
@@ -900,9 +993,20 @@ The limits, before you promise the user anything:
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
loads the model first. A local worker run by claude spends minutes on its first answer
(its fixed prompt is about 36,000 tokens).
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
has hooks and `sendwait` works as for any claude worker.
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
for those workers; `sendmark` is the way through. A claude worker has hooks, so
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
reads an empty answer from it for now.
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
menu and finish the guide.
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
local model Pi would otherwise ask to trust the folder in every session, and a task
prompt must never answer that question. Pi also fetches its two search tools at each such
start, which needs the network for a moment.
- **Spawning can change what the person set.** A stopped runtime is started, which also
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
open, the window refuses Stop and Remove and names your workers, so delete them as soon
+102 -26
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -139,12 +159,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -207,31 +238,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -246,11 +301,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -267,11 +327,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -287,6 +354,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -434,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -458,7 +529,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -545,4 +621,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
scanning an earlier run or from tmux replaying old screen content as fresh output on an
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
safe.
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
characters of the worker's id for that reason.
### 6. `jq` parse error instead of an `errorCode`
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -831,9 +831,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
```
`cli` is the default coding agent. `agents` is every agent the model can be run with
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
list, and both keys are then absent.
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
@@ -841,6 +851,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
| `unsupported` | the server has no local model routes (404) | it predates the feature |
@@ -849,29 +860,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
It never downloads, installs or starts anything. Installing is many gigabytes the person
confirms in the window, and no route this skill calls can do it.
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
the agent must be a row of `agents` whose status is `ready`; any other status, or an
agent the model cannot be run with, fails the spawn with the ones that are startable.
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
installed from here.
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
window's Start button sends, then `local_model` again until it reports `running`
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
session", with the installed agent as the mode. No advisor, which is a cloud model.
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
custom model Pi keeps no home, so it would ask to trust the folder in every session with
Trust preselected, and the task prompt's Enter would answer it.
The server checks again that its own runtime runs and that the model on disk is the one
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
person (a context below the agent's safe floor, a model swap that would evict another
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
and no `confirmed*` flag is ever sent.
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
composer draws, or the spawn is refused before a session exists.
5. Readiness: a token the agent's composer draws once it takes input, through
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
`readyMark` from `local_model` when the server sends one, else the one measured for this
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
custom model launch, none of it on a Mac yet:
| Agent | Token | What was measured |
|-------|-------|-------------------|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). An agent with no token at all is refused before a session exists.
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
+145 -41
View File
@@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -197,6 +197,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -259,9 +270,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -277,12 +297,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -345,31 +376,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -384,11 +439,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -405,11 +465,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -425,6 +492,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -572,7 +643,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -596,7 +667,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -683,10 +759,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -850,9 +926,25 @@ Three things to know before you spawn one:
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
on its welcome screen), you can hand work to that model from here. You stay the planner and
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
other mode: `spawn_workers plan review:deepseek summ:local`.
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
`spawn_workers plan review:deepseek summ:local`.
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
| Worker kind | Agent | Why you would pick it | Drive it with |
| --- | --- | --- | --- |
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
the spawn says so with the ones that are. Installing an agent is a person's click in the
**Run a local model** window, like installing a model: tell the user, do not work around it.
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
Reach for it when:
@@ -866,9 +958,9 @@ Reach for it when:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; starts a stopped runtime first; empty = failed
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
@@ -891,8 +983,9 @@ The limits, before you promise the user anything:
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
window. This skill never installs one: that is a download of many gigabytes the person
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
instead of guessing.
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
user that instead of guessing. It also lists the models on this Mac (`models`); only the
`active` one runs, and switching to another is a person's decision in the window.
- **One model, one request at a time.** The runtime loads one model and answers one request
at a time, so two local workers take turns rather than work side by side. Fan out to the
cloud; give the Mac a queue.
@@ -900,9 +993,20 @@ The limits, before you promise the user anything:
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
loads the model first. A local worker run by claude spends minutes on its first answer
(its fixed prompt is about 36,000 tokens).
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
has hooks and `sendwait` works as for any claude worker.
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
for those workers; `sendmark` is the way through. A claude worker has hooks, so
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
reads an empty answer from it for now.
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
menu and finish the guide.
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
local model Pi would otherwise ask to trust the folder in every session, and a task
prompt must never answer that question. Pi also fetches its two search tools at each such
start, which needs the network for a moment.
- **Spawning can change what the person set.** A stopped runtime is started, which also
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
open, the window refuses Stop and Remove and names your workers, so delete them as soon
+102 -26
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
@@ -139,12 +159,23 @@ local_model() {
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
elif ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
@@ -207,31 +238,55 @@ _local_ready_model() {
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
# that wants a person to confirm something (a context below the agent's safe floor, a model
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
@@ -246,11 +301,16 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
@@ -267,11 +327,18 @@ spawn_worker() {
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -287,6 +354,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -434,7 +505,7 @@ sendwait() {
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
@@ -458,7 +529,12 @@ sendmark() {
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
# calls started in one second for two workers in ONE folder shared a token, and one
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
@@ -545,4 +621,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.50.0
CODEMAN_PREAMBLE=1.51.0
+4 -1
View File
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
scanning an earlier run or from tmux replaying old screen content as fresh output on an
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
safe.
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
characters of the worker's id for that reason.
### 6. `jq` parse error instead of an `errorCode`
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
+37 -11
View File
@@ -831,9 +831,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
```
`cli` is the default coding agent. `agents` is every agent the model can be run with
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
list, and both keys are then absent.
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
@@ -841,6 +851,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
| `unsupported` | the server has no local model routes (404) | it predates the feature |
@@ -849,29 +860,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
It never downloads, installs or starts anything. Installing is many gigabytes the person
confirms in the window, and no route this skill calls can do it.
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
the agent must be a row of `agents` whose status is `ready`; any other status, or an
agent the model cannot be run with, fails the spawn with the ones that are startable.
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
installed from here.
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
window's Start button sends, then `local_model` again until it reports `running`
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
session", with the installed agent as the mode. No advisor, which is a cloud model.
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
custom model Pi keeps no home, so it would ask to trust the folder in every session with
Trust preselected, and the task prompt's Enter would answer it.
The server checks again that its own runtime runs and that the model on disk is the one
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
person (a context below the agent's safe floor, a model swap that would evict another
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
and no `confirmed*` flag is ever sent.
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
composer draws, or the spawn is refused before a session exists.
5. Readiness: a token the agent's composer draws once it takes input, through
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
`readyMark` from `local_model` when the server sends one, else the one measured for this
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
custom model launch, none of it on a Mac yet:
| Agent | Token | What was measured |
|-------|-------|-------------------|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). An agent with no token at all is refused before a session exists.
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
+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));
}
+54 -12
View File
@@ -13,10 +13,11 @@
* computed from that model's architecture. test/local-model-catalog.test.ts recomputes every
* computed figure from a table of architectures and refuses a row that is not in it, so a
* new row needs its model's config.json read, not a figure borrowed from a neighbour.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
* take a custom model is checked against the registry at call time, in
* src/local-model/harness-facts.ts, never here.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn?, readyMark? }`). The
* `cli` is a CLI registry id as data: no code may compare it with a literal. Whether that CLI
* exists, can take a custom model and can be installed from here is checked against the
* registry at call time, in src/local-model/harness-facts.ts, never here. HARNESSES below
* says what was measured for each row, and where.
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
* row, and `chips` most specific first.
@@ -56,14 +57,50 @@
* | | | faster than the run without it |
* | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed |
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. 32768 no, which is why |
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | that row is gated |
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | |
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. Nothing was run below it, |
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | and no model row offers less, so |
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | every row says 65536 (HARNESSES) |
* | | leaves 14,000 to 16,000 tokens, less than one default | |
* | | 2,000-line file read | |
* | `chips` | the bandwidth column of | yes, read at the source for this |
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
*
* HARNESSES. What each harness row rests on. The runs of 2026-11-10 were made on Linux through
* Codeman's own custom model launch, against a real model (Ollama 0.13.0, a 1.7B model on a CPU)
* where it says REAL, and against a scripted endpoint that replays that runtime's stream of a
* tool call where it says SCRIPTED. A scripted run proves the harness's side of the protocol,
* never what a model does with its prompt. None of them ran on a Mac or on the pinned runtime.
*
* | Harness | First request (fixed prompt) | Tool round trip | `readyMark` |
* |------------|-----------------------------------|----------------------------------------|--------------------------------------|
* | `opencode` | 8,115 tokens, 11 tools (v1.19.5; | SCRIPTED (v1.19.5). REAL in our own | `┃`: the first painted frame holds |
* | | more with user-level skills) | run on a Mac, see the `tested` entry | the composer; a prompt sent at the |
* | | | | mark was submitted, 2 of 2 launches |
* | `claude` | about 36,000 tokens, which is | not run again for this table (the row | none here: the CLI registry's |
* | | `slowFirstTurn` | shipped in 1.50.0) | `composerReadyMark` stands |
* | `pi` | 2,096 tokens, 4 tools (v1.5.2, | REAL (v1.5.2): tool call, tool result, | none: its composer rules are painted |
* | | the runtime's own count) | final answer, the file on disk | before it takes Enter (2 of 2 |
* | | | | prompts sent at the rule stayed |
* | | | | unsent), and it draws nothing that |
* | | | | is a contract once it does |
* | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only, on both versions | none here: the CLI registry's `❯` |
* | | (0.1.1-rc.2); 6,983 tokens, 27 | (0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1 | stands; a prompt sent at it was |
* | | tools (0.2.2-rc.1) | and 0.2.2-rc.1 with dsh-TUI 0.19.0). | submitted, 2 of 2 launches. On |
* | | | REAL: the launch and the model name | dsh-TUI 0.19.0 a setup guide draws |
* | | | reached the runtime, no turn finished | `❯` too until it was finished once |
*
* Every one of them sent the `name:tag` model id unchanged. `pi` was gated until 2026-11-10 for
* two open questions, both answered by its run: the id (above), and the window, which Codeman
* now passes to it (src/custom-model-injection.ts; without it pi takes an unknown model for a
* 128,000-token one). `deepseek` is a LAUNCHER: its install command leaves it with nothing to
* launch, so src/local-model/harness-facts.ts never reports it installable and a Mac is offered
* it only where it was already set up. It is offered on a scripted run and one unfinished real
* one: gate the row again (`gated: true`) should a run on a Mac not finish a turn. Two things
* about dsh-TUI 0.19.0 that a client of its `readyMark` must know: until a person has finished
* its setup guide once, every session opens on that guide and the guide's cursor is the mark
* (the codeman skill reads the pane after the mark for that reason), and its transcript file
* was renamed, so Codeman's `last-response` is empty for it for now.
*
* MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes`
* (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three
* figures are filled by the rule of its runner:
@@ -222,6 +259,8 @@ const LocalHarnessEntrySchema = z
rank: z.number().int(),
gated: z.boolean().optional(),
slowFirstTurn: z.boolean().optional(),
// One literal a script waits for: no white space, so the terminal's own spacing cannot split it.
readyMark: z.string().min(1).max(64).regex(/^\S+$/).optional(),
})
.strict();
@@ -528,13 +567,16 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
tested: [],
},
],
// The higher rank wins when a model can run with more than one harness.
// The higher rank wins when a model can run with more than one harness. What each row rests
// on is the HARNESSES table of the file header.
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
// The smallest first request of the four (2,096 tokens). No `readyMark`: see HARNESSES.
{ cli: 'pi', minContext: 65536, rank: 2 },
// A launcher: offered only where it is already set up, never installed from here.
{ cli: 'deepseek', minContext: 65536, rank: 1 },
],
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
+22 -14
View File
@@ -194,6 +194,21 @@ function opencodeModelLimit(contextLength: number | undefined): { context: numbe
return { context: total, output };
}
/**
* The two fields pi's model list and the dsh provider patch (which is built on pi's model
* layer) take for a model's window: `contextWindow`, the FULL window, and `maxTokens`, the
* reply's share of it (a quarter, capped at 8192 on a large window). Empty for an unknown or
* non-finite size: the CLI then keeps its own default, and no limit is invented here.
*/
function modelWindowFields(contextLength: number | undefined): { contextWindow?: number; maxTokens?: number } {
if (contextLength === undefined || !Number.isFinite(contextLength) || contextLength <= 0) return {};
return {
contextWindow: Math.trunc(contextLength),
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
};
}
function renderConfigContent(
template: 'opencode-json',
endpoint: CustomModelEndpoint,
@@ -260,19 +275,7 @@ function renderConfigFile(
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
api: 'openai-completions',
baseURL: baseUrl,
models: [
{
id: modelId,
name: modelId,
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
? {
contextWindow: Math.trunc(contextLength),
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
}
: {}),
},
],
models: [{ id: modelId, name: modelId, ...modelWindowFields(contextLength) }],
},
},
},
@@ -327,6 +330,11 @@ function renderConfigFile(
// true` is required too: pi does not automatically send `Authorization: Bearer
// <apiKey>` just because `apiKey` is set (per the same doc) — without it, a real
// (non-llama.cpp) endpoint that actually checks the key would reject every request.
// The model entry carries the endpoint's context window when it is known. Without it pi
// takes an unknown model for a 128,000-token one (its footer reads `/128k`) and asks for
// 16,384 reply tokens, so on a 65,536 or 32,768 window it never compacts before the
// server's real window is full. Measured on pi 1.5.2: with both fields `pi --list-models`
// shows the window and the request carries `max_tokens: 8192`.
return {
content: JSON.stringify(
{
@@ -336,7 +344,7 @@ function renderConfigFile(
apiKey,
api: 'openai-completions',
authHeader: true,
models: [{ id: modelId }],
models: [{ id: modelId, ...modelWindowFields(contextLength) }],
},
},
},
+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',
+4
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));
+3
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);
@@ -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
+6
View File
@@ -62,6 +62,7 @@ import { homedir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
// ─── Limits ──────────────────────────────────────────────────────────────────
@@ -371,6 +372,11 @@ const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=fals
*/
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
new Promise<WorktreeGitResult>((resolveRun) => {
// Never Apple's placeholder git on a Mac without developer tools: answered as "git is not installed".
if (!gitSpawnAllowed()) {
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(gitMissingError()) });
return;
}
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', args, {
+103 -5
View File
@@ -1358,6 +1358,87 @@ async function isSymlink(path: string): Promise<boolean> {
}
}
/**
* Whether a folder INSIDE the copy that holds skill files (today only `reference/`) is a
* symlink. The checks on the copy and its parent stop at the copy itself, and a write or
* unlink of `reference/<name>.md` follows a linked `reference/` wherever it points:
* measured, `reference -> <elsewhere>` made install overwrite a `history.md` there and
* uninstall delete it. A cloned repository can commit that link, and a Docker case's
* container can plant it in the bind-mounted workspace that the host injects into, so
* both are refused like a linked copy. Checked once before the first write or unlink; a
* link swapped in after the check (a race against the container) is not covered.
*/
async function agentSkillSubdirIsSymlink(skillDir: string, files: AgentSkillFile[]): Promise<boolean> {
const subdirs = new Set<string>(['reference']);
for (const file of files) {
// Every folder on the way to the file, outermost first: `a/b/x.md` checks `a`, then `a/b`.
const parts = dirname(file.relPath)
.split(/[\\/]/)
.filter((part) => part !== '' && part !== '.');
for (let i = 1; i <= parts.length; i++) subdirs.add(join(...parts.slice(0, i)));
}
for (const subdir of subdirs) {
if (await isSymlink(join(skillDir, subdir))) return true;
}
return false;
}
/**
* The ignore file every Codeman-managed skill copy carries, and what it holds: `*`
* matches everything in the copy's own folder (the ignore file included), so a user's
* repository never lists `.claude/skills/codeman/` (or `.agents/skills/codeman/`) as
* untracked, without Codeman touching the repository's own `.gitignore` or
* `info/exclude`. Same idea as the prompt-uploads folder (`ensureUploadDir`).
*
* Deliberately NOT part of `readAgentSkillSource()`: it is written once with O_EXCL and
* never compared or rewritten, so a file the user put there (or edited) is theirs.
*
* Measured against Claude Code 2.1.319, whose 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: a `*` inside `codeman/` ignores the folder's CONTENTS,
* not the folder, so the skill still loads. Codex 0.162 does no gitignore check on skills.
*/
const AGENT_SKILL_GITIGNORE = '.gitignore';
const AGENT_SKILL_GITIGNORE_CONTENT = '*\n';
/**
* Write the copy's `.gitignore` if none is there. Returns whether it wrote one.
* 'wx' (O_CREAT|O_EXCL) never replaces an existing file and refuses a symlink at the
* name. A create that succeeded before the write failed (ENOSPC) would leave an empty
* file that reads as the user's next time, so it is taken back; that cleanup's own
* failure must not replace the cause.
*/
async function ensureAgentSkillGitignore(skillDir: string): Promise<boolean> {
const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE);
try {
await writeFile(ignoreFile, AGENT_SKILL_GITIGNORE_CONTENT, { flag: 'wx' });
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
await unlink(ignoreFile).catch(() => {});
throw err;
}
}
/**
* Remove the copy's `.gitignore` only when it is the LAST thing in the folder and holds
* exactly what Codeman writes. Anything else in the folder (a user's own notes) keeps
* the ignore file, so those files stay out of `git status` as they were, and a
* `.gitignore` with other content is the user's.
*/
async function removeAgentSkillGitignoreIfLast(skillDir: string): Promise<void> {
const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE);
try {
const entries = await readdir(skillDir);
if (entries.length !== 1 || entries[0] !== AGENT_SKILL_GITIGNORE) return;
if (!(await lstat(ignoreFile)).isFile()) return;
if ((await readFile(ignoreFile, 'utf-8')) !== AGENT_SKILL_GITIGNORE_CONTENT) return;
await unlink(ignoreFile);
} catch {
// best-effort: a folder that stays behind holds only an ignore file
}
}
/** What an install/remove actually did, so callers (CLI, logs) can say so. */
export type AgentSkillApplyResult =
| 'installed' // fresh copy written
@@ -1366,23 +1447,31 @@ export type AgentSkillApplyResult =
| 'removed' // our copy deleted
| 'absent' // nothing there to remove
| 'foreign' // a copy exists but is not ours; left untouched
| 'symlink'; // the skill dir (or its parent) is a symlink; left untouched
| 'symlink'; // the skill dir, its parent or a folder inside it is a symlink; left untouched
/**
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
* `<case>/.agents/skills/codeman`).
*
* Refuses two shapes rather than writing through them:
* Refuses these shapes rather than writing through them:
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
* layout (`.claude/skills/codeman -> ../../skills/codeman`) would otherwise have
* the injector overwrite the repo source through the link;
* - a SYMLINK at a folder inside the copy (`reference/`), which would carry the skill's
* files to wherever it points (`agentSkillSubdirIsSymlink`);
* - a FOREIGN copy (SKILL.md present without our marker): that is the user's own
* skill, and per the statusLine rule we never clobber what we did not write.
*
* Idempotent and cheap: unchanged files are not rewritten, so calling on every
* session create causes no mtime churn.
*
* The copy ignores itself in git: a `.gitignore` of `*` is written FIRST, before any
* skill file, so a `git status` that runs mid-install never lists the folder either
* (`ensureAgentSkillGitignore`). It is written once and never rewritten; a copy made
* before this existed gains it on its next install or refresh, which then reports
* 'refreshed'.
*
* Serialized on the skill dir through the same lock the settings writers use: two
* sessions created at once in one repo both inject this skill, and interleaving their
* ownership read with the other's write reports a bogus result (an 'unchanged' for a
@@ -1402,7 +1491,9 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
if (existing !== null && !existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign';
const files = await readAgentSkillSource();
let changed = false;
if (await agentSkillSubdirIsSymlink(skillDir, files)) return 'symlink';
await mkdir(skillDir, { recursive: true });
let changed = await ensureAgentSkillGitignore(skillDir);
for (const file of files) {
const target = join(skillDir, file.relPath);
let current: string | null = null;
@@ -1544,7 +1635,8 @@ export async function refreshUserAgentSkill(): Promise<AgentSkillApplyResult | '
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
* refusals as the install path. Deletes only files the packaged source would have
* written (never `rm -rf`, so a user's extra files in the directory survive), then
* prunes the directories bottom-up if they emptied.
* the copy's own `.gitignore` when nothing else is left beside it, then prunes the
* directories bottom-up if they emptied.
*
* Shares the install path's per-dir lock so an uninstall can't run between an install's
* ownership read and its writes, which would leave half the skill back on disk.
@@ -1564,10 +1656,13 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
// Manifest-based, with SKILL.md as the fallback when the packaged source is
// unreadable: removal must still work on an install whose skills/ dir went missing.
const files = await readAgentSkillSource().catch((): AgentSkillFile[] => [{ relPath: 'SKILL.md', content: '' }]);
// An unlink through a linked `reference/` deletes files wherever it points.
if (await agentSkillSubdirIsSymlink(skillDir, files)) return 'symlink';
for (const file of files) {
await unlink(join(skillDir, file.relPath)).catch(() => {});
}
await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine
await removeAgentSkillGitignoreIfLast(skillDir);
await rmdir(skillDir).catch(() => {});
await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
return 'removed';
@@ -1601,7 +1696,10 @@ export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string {
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
* shape. Gated by the synced `agentSkillEnabled` app setting: default ON since 1.50.0
* (an absent key is on, only an explicit false is off; `getAgentSkillEnabled()` in
* server.ts), which is safe for a user's repository because the copy ignores itself
* in git (`ensureAgentSkillGitignore`).
*
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
+223 -31
View File
@@ -13,17 +13,30 @@
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
* changes, and a file read starts no process, no repository config and no filter.
*
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
* A folder strictly below `workingDir` is the plain answer: the file routes already serve
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
* only move where the listing STARTS inside that same folder. A worktree outside it
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
* confinement root.
* only move where the listing STARTS inside that same folder.
*
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
* judging every path against `workingDir`.
* A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree that a
* `WorktreeCreate` hook places beside the repository) is a second confinement root, so it is
* answered only when the caller passes {@link OutsideWorktreeOptions}, and only when the session's
* own repository vouches for it ({@link findOutsideWorktree}). The answer then carries
* `outside: true`, and the file routes serve absolute paths inside it.
*
* For a worktree inside `workingDir` the answer is lexical and only as fresh as the last time the
* pane moved: a symlink can take the folder's place afterwards without moving the pane. So
* nothing there is a confinement decision. `GET /api/sessions/:id/files` resolves the folder on
* every request and walks it only while it really lies inside `workingDir`
* (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep judging every path against
* `workingDir`.
*
* For an outside worktree {@link findOutsideWorktree} IS the confinement decision for the second
* root. Its vouching, home and Docker rules, and the multi-user half of `allowRoot`, run only when
* the pane moves. The routes re-check two things on each request: that the folder still realpaths
* to the recorded path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), and the attachment
* guard half of `allowRoot` (workspace confinement, blocked and sensitive trees:
* `isOutsideWorktreeRootRefused()` through `withGuardedOutsideWorktree()` in file-routes.ts), so a
* guard setting changed later applies at once.
*
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
* `unknown` answer stops the walk rather than being read as "no `.git` here".
@@ -32,15 +45,44 @@
*/
import { constants, promises as fs } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
/** A linked worktree a session's pane is in. */
export interface LiveWorktree {
/** The worktree folder's name, for display. */
name: string;
/** Absolute path, spelled like the session's `workingDir`. */
/**
* Absolute path. Spelled like the session's `workingDir` for a worktree inside it; the real
* path (no symlink in any component) for one outside it.
*/
path: string;
/**
* The worktree lies outside `workingDir`, so it is a second root the file routes serve
* ABSOLUTE paths from. Absent for a worktree inside `workingDir`.
*/
outside?: true;
}
/** What {@link findLiveWorktree} needs to answer a worktree outside `workingDir`. */
export interface OutsideWorktreeOptions {
/** Overrides `os.homedir()` (tests). */
home?: string;
/** Host paths of every Docker case workspace: a container can write there, so none is followed. */
dockerWorkspaces?: () => Promise<string[]>;
/**
* The caller's last word on which worktree may be followed. The server passes its attachment
* guard rules (workspace confinement, blocked trees, sensitive folders) in every mode, plus the
* owner's folder rule (`folderAllowedForUser`) in multi-user mode, so a regular user's session
* cannot list another user's checkout. A rejection counts as a refusal.
*/
allowRoot?: (root: string) => boolean | Promise<boolean>;
}
/** The text after `gitdir:` in a `.git` file, as written (possibly relative). */
function rawGitDir(text: string): string | undefined {
return /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
}
/** More than any real `.git` file. It is read only to recognise the first line. */
@@ -52,12 +94,33 @@ const MAX_DOT_GIT_FILE_BYTES = 4096;
* The path may be relative: `git worktree add --relative-paths` writes it that way.
*/
export function isLinkedWorktreeGitFile(text: string): boolean {
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
const gitDir = rawGitDir(text);
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/**
* The text of a small file, opened so that nothing blocks or escapes. The probe a caller ran
* follows symlinks and the file can change after it, so the open itself refuses: O_NOFOLLOW a
* symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and hold a threadpool
* worker for good. Whatever it did open is then judged on the handle. Null for anything but a
* regular file it could read.
*/
async function readOpenedSmallFile(path: string): Promise<string | null> {
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return null;
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return buffer.toString('utf8', 0, bytesRead);
} catch {
return null;
} finally {
await handle?.close().catch(() => {});
}
}
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
const dotGit = join(dir, '.git');
@@ -65,32 +128,28 @@ async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'
if (kind === 'absent') return 'none';
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
if (kind !== 'file') return 'stop';
let handle: fs.FileHandle | undefined;
try {
// The probe follows symlinks and the file can change after it, so the open itself refuses:
// O_NOFOLLOW a symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and
// hold a threadpool worker for good. Whatever it did open is then judged on the handle.
handle = await fs.open(dotGit, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return 'stop';
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
} catch {
return 'stop';
} finally {
await handle?.close().catch(() => {});
}
// `null` is a symlink, a FIFO, a non-regular file or an unreadable one: all stop the walk.
const text = await readOpenedSmallFile(dotGit);
return text !== null && isLinkedWorktreeGitFile(text) ? 'worktree' : 'stop';
}
/**
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
* read. `workingDir` itself is never an answer: a session started in a worktree is already
* listing it. Never throws.
* The linked worktree that holds `cwd`, if any. Walks up from `cwd` and stops at `workingDir`,
* at a repository or submodule of its own, or at anything it cannot read. `workingDir` itself is
* never an answer: a session started in a worktree is already listing it. When `cwd` lies outside
* `workingDir` and `outside` is given, {@link findOutsideWorktree} answers instead. Never throws.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
export async function findLiveWorktree(
workingDir: string,
cwd: string | undefined,
outside?: OutsideWorktreeOptions
): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
if (cwd !== root && !cwd.startsWith(under)) {
return outside ? findOutsideWorktree(root, cwd, outside).catch(() => undefined) : undefined;
}
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
const found = await classifyDotGit(dir);
if (found === 'worktree') return { name: basename(dir), path: dir };
@@ -98,3 +157,136 @@ export async function findLiveWorktree(workingDir: string, cwd: string | undefin
}
return undefined;
}
/** True when `child` is `parent` or lies below it. Both must already be real paths. */
const isSameOrUnder = (child: string, parent: string): boolean =>
child === parent || child.startsWith(parent === sep ? parent : parent + sep);
/** The real path of `p` once a bounded probe has answered for it, else null. */
async function realPathBounded(p: string): Promise<string | null> {
const kind = await probePathKind(p);
if (kind !== 'directory' && kind !== 'file') return null;
try {
return await fs.realpath(p);
} catch {
return null;
}
}
/** The text of a small regular file that a bounded probe found, via {@link readOpenedSmallFile}. */
async function readSmallFile(path: string): Promise<string | null> {
return (await probePathKind(path)) === 'file' ? readOpenedSmallFile(path) : null;
}
/** The `gitdir:` value of a `.git` file's text, resolved against the folder that holds it. */
function gitDirOf(text: string, dir: string): string | null {
const raw = rawGitDir(text);
return raw ? resolve(dir, raw) : null;
}
/**
* The real path of the git common dir of the repository whose top level is `workingDir`: its
* `.git` folder, or, for a linked worktree, the folder its `commondir` file names. Null when
* `workingDir` is not the top level of a repository or worktree: a session in a subfolder
* follows no worktree outside it.
*/
async function sessionCommonDir(workingDir: string): Promise<string | null> {
const dotGit = join(workingDir, '.git');
const kind = await probePathKind(dotGit);
if (kind === 'directory') return realPathBounded(dotGit);
if (kind !== 'file') return null;
const text = await readSmallFile(dotGit);
const gitDir = text ? gitDirOf(text, workingDir) : null;
if (!gitDir) return null;
// A linked worktree's git dir names the common dir in `commondir`; a submodule's git dir
// has none and is its own common dir.
const commonDir = await readSmallFile(join(gitDir, 'commondir'));
return realPathBounded(commonDir ? resolve(gitDir, commonDir.trim()) : gitDir);
}
/**
* The worktree outside `workingDir` that holds `cwd`, when the session's own repository vouches
* for it. `cwd` comes from the pane, which a prompt-injected agent controls, so every rule below
* keeps it from widening what the file routes serve beyond a worktree of the session's repository:
*
* - The walk up from `cwd` stops at the first `.git`. That folder is a candidate only when its
* `.git` FILE names `<common dir>/worktrees/<id>` of the session's repository AND that
* entry's own `gitdir` file names the candidate back. A `.git` FOLDER is a candidate only when
* it IS the session's common dir (the main worktree, for a session started in a linked one).
* These are the files `git worktree list` reads, read here without starting git: this runs
* from the pane watcher on every directory change.
* - `/`, the home folder and anything above it, and any folder holding `workingDir` are
* refused, as is a folder at, inside or around a Docker case workspace.
* - `allowRoot`, when given, has the last word (the server's attachment-guard and multi-user rules).
*
* Never rejects: an `allowRoot` that rejects refuses the worktree.
*
* Every path is compared as a real path, and the answer is the real path. The file routes check
* on each request that the folder still resolves to itself, so a symlink swapped in later is
* refused there. A forged registry entry needs write access to the session's own `.git`, which
* an agent running as this user already has, along with read access to everything it could list.
*/
export async function findOutsideWorktree(
workingDir: string,
cwd: string,
opts: OutsideWorktreeOptions
): Promise<LiveWorktree | undefined> {
// The nearest folder at or above `cwd` that holds a `.git`, and what that `.git` is.
let dir = cwd;
let kind: 'directory' | 'file';
for (;;) {
const probed = await probePathKind(join(dir, '.git'));
if (probed === 'unknown') return undefined;
if (probed === 'directory' || probed === 'file') {
kind = probed;
break;
}
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
const commonDir = await sessionCommonDir(workingDir);
if (!commonDir) return undefined;
const real = await realPathBounded(dir);
if (!real) return undefined;
if (kind === 'directory') {
// The main worktree: its `.git` folder is the session's common dir itself. The folder must
// really hold it: a `.git` symlink resolves elsewhere, so it fails this comparison.
if (join(real, '.git') !== commonDir) return undefined;
} else {
const text = await readSmallFile(join(dir, '.git'));
const gitDir = text && isLinkedWorktreeGitFile(text) ? gitDirOf(text, dir) : null;
const realGitDir = gitDir ? await realPathBounded(gitDir) : null;
if (!realGitDir || dirname(realGitDir) !== join(commonDir, 'worktrees')) return undefined;
// The registry entry must name this folder back, as `git worktree list` would report it.
const back = await readSmallFile(join(realGitDir, 'gitdir'));
const backDir = back ? await realPathBounded(dirname(resolve(realGitDir, back.trim()))) : null;
if (backDir !== real) return undefined;
}
const realWorkingDir = await realPathBounded(workingDir);
if (!realWorkingDir || isSameOrUnder(realWorkingDir, real) || isSameOrUnder(real, realWorkingDir)) return undefined;
const home = (await realPathBounded(opts.home ?? homedir())) ?? resolve(opts.home ?? homedir());
if (real === sep || isSameOrUnder(home, real)) return undefined;
if (opts.dockerWorkspaces) {
const workspaces = await opts.dockerWorkspaces().catch(() => null);
// An unreadable list could hide a container's workspace, so it fails closed.
if (!workspaces) return undefined;
for (const workspace of workspaces) {
const realWorkspace = (await realPathBounded(workspace)) ?? resolve(workspace);
if (isSameOrUnder(real, realWorkspace) || isSameOrUnder(realWorkspace, real)) return undefined;
}
}
if (opts.allowRoot) {
let allowed = false;
try {
allowed = await opts.allowRoot(real);
} catch {
// A callback that throws or rejects refuses the worktree.
}
if (!allowed) return undefined;
}
return { name: basename(real), path: real, outside: true };
}
+18 -4
View File
@@ -263,16 +263,30 @@ export async function isManagedLocalEndpoint(id: string): Promise<boolean> {
}
/**
* The names of the live sessions that use the endpoint `endpointId`, in the order given.
* Pure. What `DELETE /api/local-model` answers its 409 with.
* What a session is called where a person reads it. A session an agent started (a worker of
* the codeman skill, `codeman agent spawn`) has no name of its own, and its tab then shows
* the last folder of its working directory, else the first eight characters of its id
* (`getSessionName()` in app.js). The same steps here, so that a sentence which lists
* sessions never lists an empty name. Pure.
*/
export function sessionLabel(session: { id?: string; name?: string; workingDir?: string }): string {
const name = (session.name ?? '').trim();
if (name) return name;
const folder = (session.workingDir ?? '').split('/').filter(Boolean).pop();
return folder || (session.id ?? '').slice(0, 8);
}
/**
* The live sessions that use the endpoint `endpointId`, in the order given, each by the name
* its tab shows (`sessionLabel()`). Pure. What the feature answers a 409 `sessions-open` with.
*/
export function sessionNamesUsingEndpoint(
sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>,
sessions: Iterable<{ id?: string; name: string; workingDir?: string; customModel?: { endpointId: string } }>,
endpointId: string = LOCAL_MODEL_ENDPOINT_ID
): string[] {
const names: string[] = [];
for (const session of sessions) {
if (session.customModel?.endpointId === endpointId) names.push(session.name);
if (session.customModel?.endpointId === endpointId) names.push(sessionLabel(session));
}
return names;
}
+13 -5
View File
@@ -9,14 +9,18 @@
* mechanism (`capabilities.customModelInjection.kind` is `unsupported`);
* - reports `label` from the registry entry and `installed` from the same probe the page
* render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build;
* - reports `installable` only for a STOCK CLI that is not installed and whose install
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`),
* and then carries that command as `installCommand`, so the consent screen can print the
* - reports `installable` only for a STOCK CLI that is not installed, whose install command
* for this platform needs no administrator rights (`installCommandNeedsAdmin()`) and which
* is not a launcher (`discovery.launcherProfile`: its install command leaves it with nothing
* to launch, and what it launches is third-party code with a consent of its own in the Run
* menu), and then carries that command as `installCommand`, so the consent screen can print the
* literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the
* one a `clis.json` override supplies: it is exactly what the install job would run, and
* an override file must not be able to change that;
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
* applies to the model id before anything is written.
* applies to the model id before anything is written;
* - passes on the registry's `capabilities.composerReadyMark` as `readyMark` when it holds no
* white space (a script waits for it as one literal).
*
* Rules:
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
@@ -156,19 +160,23 @@ export async function buildHarnessFacts(
}
let installCommand: string | undefined;
if (!installed && entry.stock) {
// A launcher (`discovery.launcherProfile`) is not startable after its install command: it
// still needs something to launch, which that command does not install.
if (!installed && entry.stock && entry.discovery.launcherProfile === undefined) {
const command = resolved.installCommandFor(entry);
if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) {
installCommand = command;
}
}
const registryMark = entry.capabilities.composerReadyMark;
facts.push({
cli: row.cli,
label: entry.label,
installed,
installable: installCommand !== undefined,
...(installCommand !== undefined ? { installCommand } : {}),
...(registryMark !== undefined && !/\s/.test(registryMark) ? { readyMark: registryMark } : {}),
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
});
}
File diff suppressed because it is too large. Load diff
+39
View File
@@ -808,6 +808,45 @@ export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise<
return freed;
}
/** How many files of the blob folder are counted at most: a store holds a few dozen, and a walk must end. */
const STORE_MEASURE_MAX_FILES = 20_000;
/**
* What the model store takes on disk: the sizes of the regular files directly in
* `<models>/blobs`, summed. That folder is flat (one file per layer, plus the partial files of
* a download), so this is the store's real disk use, shared layers counted once, an
* interrupted download included. The manifests beside it are a few kilobytes and are left out.
*
* One `readdir` and one `lstat` per file, at most `STORE_MEASURE_MAX_FILES` of them. Nothing is
* followed: a `root`, a model store or a blob folder that is a symbolic link counts as empty,
* and so does a link in the place of a file. Never outside `LocalModelPaths.root`. Resolves 0
* when there is no store. Never throws.
*/
export async function measureModelStore(paths?: LocalModelPaths): Promise<number> {
let total = 0;
try {
const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version);
const blobs = join(resolved.modelsDir, 'blobs');
if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0;
for (const folder of [resolved.root, resolved.modelsDir, blobs]) {
const info = await lstat(folder);
if (info.isSymbolicLink() || !info.isDirectory()) return 0;
}
const names = await readdir(blobs);
for (const name of names.slice(0, STORE_MEASURE_MAX_FILES)) {
try {
const info = await lstat(join(blobs, name));
if (info.isFile()) total += info.size;
} catch {
// Gone between the listing and the look: a download or a delete is under way.
}
}
} catch {
// No model store yet.
}
return total;
}
// ---------- Port ----------
/** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */
+39
View File
@@ -26,6 +26,7 @@ import {
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelLibraryEntry,
type LocalModelStateFile,
} from '../types.js';
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
@@ -153,6 +154,41 @@ function parseConnection(value: unknown): LocalModelStateFile['connection'] {
: undefined;
}
/** How many checked models a state file may list: far more than one Mac holds, and a bound on what a damaged file can cost. */
const MAX_LIBRARY_ENTRIES = 64;
/**
* The checked models of a file. An entry without its id, label, context or digest is dropped
* (it could never be switched to), and so is a second entry for an id. `undefined` for a file
* that has no list, which is what an earlier build wrote.
*/
function parseLibrary(value: unknown): LocalModelLibraryEntry[] | undefined {
if (!Array.isArray(value)) return undefined;
const entries: LocalModelLibraryEntry[] = [];
for (const item of value) {
if (entries.length >= MAX_LIBRARY_ENTRIES) break;
if (!isRecord(item)) continue;
const id = text(item.id, 200);
const label = text(item.label, 200);
const context = count(item.context);
const digest = text(item.digest, 200);
if (!id || !label || !context || !digest || entries.some((entry) => entry.id === id)) continue;
const bytes = count(item.bytes);
const cli = parseCli(item.cli);
const verifiedAt = text(item.verifiedAt, 64);
entries.push({
id,
label,
context,
digest,
...(bytes ? { bytes } : {}),
...(cli ? { cli } : {}),
...(verifiedAt ? { verifiedAt } : {}),
});
}
return entries;
}
/**
* The state a file's text stands for. Pure and total: text that is not a JSON object of
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
@@ -180,6 +216,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
const connection = parseConnection(parsed.connection);
const updatedAt = text(parsed.updatedAt, 64);
const replacedModelId = text(parsed.replacedModelId, 200);
const library = parseLibrary(parsed.library);
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
if (state === 'installed') {
@@ -192,6 +229,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
cli,
runtime,
...(loaded ? { loaded } : {}),
...(library ? { library } : {}),
...(connection ? { connection } : {}),
...(updatedAt ? { updatedAt } : {}),
};
@@ -215,6 +253,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
...(runtime ? { runtime } : {}),
...(progress ? { progress } : {}),
...(loaded ? { loaded } : {}),
...(library ? { library } : {}),
...(connection ? { connection } : {}),
...(replacedModelId ? { replacedModelId } : {}),
...(updatedAt ? { updatedAt } : {}),
+3
View File
@@ -21,6 +21,7 @@ import { open, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
import type { PredictionToolCall, WorkspaceSignals } from './readmymind-context.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
const execFileAsync = promisify(execFile);
@@ -150,6 +151,8 @@ const MAX_STATUS_LINES = 30;
*/
export async function collectWorkspaceSignals(workingDir: string): Promise<WorkspaceSignals | null> {
const git = async (args: string[]): Promise<string> => {
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
if (!gitSpawnAllowed()) throw gitMissingError();
const { stdout } = await execFileAsync('git', args, {
cwd: workingDir,
timeout: GIT_TIMEOUT_MS,
+10 -5
View File
@@ -1657,10 +1657,13 @@ export class Session extends EventEmitter {
}
/**
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
* never a confinement root, like `paneCwd`: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request.
* The git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`): a linked
* worktree inside `workingDir`, or, for an outside answer, a linked worktree or the repository's
* main worktree (from a session started in a linked one).
* The Files panel lists it instead of the main checkout. Runtime-only, like `paneCwd`. One inside
* `workingDir` is never a confinement root: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request. One outside it (`outside: true`) is
* a second root for the file routes, re-checked on every request the same way.
*/
get liveWorktree(): LiveWorktree | undefined {
return this._liveWorktree;
@@ -2452,7 +2455,9 @@ export class Session extends EventEmitter {
status: this._status,
workingDir: this.workingDir,
paneCwd: this._paneCwd,
liveWorktree: this._liveWorktree,
// The wire keeps `liveWorktree` inside `workingDir` only; an outside answer rides its own field.
liveWorktree: this._liveWorktree?.outside ? undefined : this._liveWorktree,
outsideWorktree: this._liveWorktree?.outside ? this._liveWorktree : undefined,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
+226 -14
View File
@@ -24,7 +24,11 @@
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
* | | | already has the reserved id; 409 `job-running`, |
* | | | `already-installed`, `size-changed` |
* | | | `already-installed` (this model at this context is |
* | | | the one in use), `size-changed`, `sessions-open` |
* | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding |
* | | | agent for it is installed; 409 `job-running`, |
* | | | `wrong-state`, `already-installed`, `sessions-open` |
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
* | | | failed), `sessions-open` (another port while live |
@@ -33,6 +37,9 @@
* | | | `sessions-open` (stop, or another port, while live |
* | | | sessions use the model) |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, |
* | | | `wrong-state`, `model-active`, `runtime-not-running`; |
* | | | 500 the runtime did not delete it |
*
* A 409 is the error envelope plus `data: LocalModelConflict`:
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
@@ -141,7 +148,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
* | `connect-failed` | the endpoint record or the settings flag could not be written |
* | `model-changed` | installed: the model's digest is not the one stored at install time |
* | `model-changed` | installed: the model's digest is not the one stored at install time. A |
* | | switch: the model's digest is not the one it was checked with |
* | `job-unfinished` | a job that changes the model in use is running or stopped. Only a |
* | | refused session start says it: nothing is started until the job ended |
* | `internal` | anything unexpected (the exception text is in `detail`) |
*/
export type LocalModelErrorCode =
@@ -164,6 +174,7 @@ export type LocalModelErrorCode =
| 'endpoint-id-taken'
| 'connect-failed'
| 'model-changed'
| 'job-unfinished'
| 'internal';
/**
@@ -192,12 +203,28 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
* - `cancel`: `POST /api/local-model/cancel`.
* - `resume`: `POST /api/local-model/resume`.
* - `start` and `stop`: `POST /api/local-model/runtime`.
* - `remove`: `DELETE /api/local-model`.
* - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store
* still holds something (models an earlier Remove kept): it is then what frees that space.
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
* blocks a session.
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
* offers it.
* - `install-agent`: `POST /api/local-model/agent`. Listed only while a row of `harnesses`
* offers it.
*/
export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session';
export type LocalModelAction =
| 'install'
| 'cancel'
| 'resume'
| 'start'
| 'stop'
| 'remove'
| 'start-session'
| 'use'
| 'delete-model'
| 'install-agent';
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
export type LocalModelDeveloperTools =
@@ -225,7 +252,9 @@ export type LocalModelDeveloperTools =
* | `errHarnessFailed` | `cli`: the coding agent's label |
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
* | `refusedOtherModel` | `model`: the label of the model that is installed |
* | `refusedOtherModel` | `model`: the label of the model that is in use |
* | `refusedAgentMissing` | `cli`: the coding agent's label |
* | `conflictModelActive`, `deleteFailed` | `model`: the model's label |
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
*
@@ -259,16 +288,23 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
errConnectFailed: 'Codeman could not save the connection to the model.',
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
errJobUnfinished:
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.',
errInternal: 'The install stopped on an unexpected error.',
conflictJobRunning: 'An install is already running.',
conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.',
conflictAlreadyInstalled: 'This model is already in use on this Mac.',
conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.',
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
conflictWrongState: 'That is not possible in the current state.',
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.',
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
refusedNotOnThisMac: 'This model is not on this Mac.',
deleteFailed: '{model} could not be deleted from this Mac.',
refusedAgentMissing: '{cli} is not installed on this Mac. Install a coding agent in the local model window first.',
endpointManaged:
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
@@ -307,6 +343,7 @@ export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelIns
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
'job-unfinished': LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished,
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
};
@@ -331,6 +368,7 @@ export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextS
'endpoint-id-taken': 'retry',
'connect-failed': 'retry',
'model-changed': 'remove',
'job-unfinished': 'retry',
internal: 'retry',
};
@@ -426,7 +464,9 @@ export interface LocalModelLoadedFacts {
* | `port` | | once chosen | once chosen | once chosen | yes |
* | `loaded`, `endpointId` | | | | | yes |
*
* `running`, `developerTools`, `runtime`, `folder` and `actions` are always present.
* `running`, `developerTools`, `runtime`, `folder`, `library`, `harnesses` and `actions` are
* always present. `store` is present once the model store was measured. `harnesses` is empty
* in every state but `installed`.
*/
export interface LocalModelInstallView {
state: LocalModelInstallState;
@@ -437,7 +477,11 @@ export interface LocalModelInstallView {
progress?: LocalModelProgress;
error?: LocalModelInstallError;
model?: LocalModelInstalledModel;
/** The coding agent: a CLI registry id, as data. */
/**
* The coding agent of the job, and on an installed model the DEFAULT one: the agent it was
* installed with, which a client that names no agent starts. A CLI registry id, as data.
* `harnesses` lists every agent a session can be started with.
*/
cli?: string;
/** The coding agent's display label ('OpenCode'). */
cliLabel?: string;
@@ -454,10 +498,127 @@ export interface LocalModelInstallView {
loaded?: LocalModelLoadedFacts;
/** The id of the saved endpoint a session is started on. */
endpointId?: string;
/**
* The models on this Mac: every model Codeman checked (`LocalModelStateFile.library`), plus
* whatever else the running runtime lists. Empty in `idle` (no runtime is there to ask) and
* before anything was downloaded. The model in use comes first.
*/
library: LocalModelLibraryRow[];
/** What the model store takes on disk right now. Present in every state once it was measured. */
store?: LocalModelStoreView;
/**
* The coding agents a session on the model in use can be started with: every catalogue
* harness that works at the model's context and accepts its name, the default one (`cli`)
* first, then by catalogue rank. The agent is chosen per session, so a row is not a setting.
* Empty in every state but `installed`.
*/
harnesses: LocalModelHarnessRow[];
/** What the server accepts right now, see `LocalModelAction`. */
actions: LocalModelAction[];
}
/**
* How a coding agent of `harnesses` stands on this Mac.
* - `ready`: it can be started (the same answer the Run menu gets).
* - `installable`: it is not on this Mac, and `POST /api/local-model/agent` installs it with
* `installCommand`.
* - `installing`: that install is running. It runs beside the model: nothing about the model
* in use changes, and a session with another agent can be started meanwhile.
* - `setup-needed`: it is not startable and one command does not make it so (a launcher
* without a profile, or a command that needs an administrator). Its own setup is in the Run
* menu or in a terminal; nothing is offered here.
*/
export type LocalModelHarnessStatus = 'ready' | 'installable' | 'installing' | 'setup-needed';
/** What can be done with one row of `harnesses`: start a session with it, or `POST /api/local-model/agent`. */
export type LocalModelHarnessAction = 'start-session' | 'install';
/** One coding agent the model in use can be run with. Everything a client needs is here, so it compares no CLI id. */
export interface LocalModelHarnessRow {
/** The CLI registry id, as data: the `mode` of a session started with it, and the `cli` of `POST /api/local-model/agent`. */
cli: string;
/** The registry's display label ('OpenCode'). */
cliLabel: string;
/** True on the row `LocalModelInstallView.cli` names. */
default: boolean;
status: LocalModelHarnessStatus;
/**
* The literal command `POST /api/local-model/agent` would run: the STOCK command of the CLI
* registry for this platform. Present exactly when `status` is `installable` or `installing`.
* The consent prints it as it is.
*/
installCommand?: string;
/** The harness row's `slowFirstTurn`: the first answer takes minutes. */
slowFirstTurn?: boolean;
/**
* A literal with no white space the agent's composer draws once it takes input
* (`LocalHarnessEntry.readyMark`, else the CLI registry's `composerReadyMark`): what a script
* waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt. Absent
* for an agent with no measured mark.
*/
readyMark?: string;
/**
* The last install of this agent through `POST /api/local-model/agent` failed, and it is
* still not on this Mac. Kept in memory only: a restart of the server forgets it.
*/
error?: LocalModelAgentError;
/** What the server accepts for this row right now. */
actions: LocalModelHarnessAction[];
}
/** Why an install of a coding agent from an installed model failed. */
export interface LocalModelAgentError {
/** `errHarnessFailed` with the agent's label. */
message: LocalModelInstallMessage;
/** The end of what the installer printed, as it is (English or the tool's own language). */
detail?: string;
}
/**
* How a model of the library stands.
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
* digest that was stored.
* - `changed`: checked, but the runtime now lists another digest under the name. It is never
* started: `use` would fail with `model-changed`.
* - `missing`: checked, but the runtime no longer lists it (something deleted it through the
* runtime's port).
* - `unverified`: the runtime lists it and Codeman never checked it: a model an earlier
* Remove kept, the model of a job that has not passed its check yet, or one another program
* pulled through the runtime's port. Never startable.
*/
export type LocalModelLibraryStatus = 'ready' | 'changed' | 'missing' | 'unverified';
/** What can be done with one row of `library`: `POST /api/local-model/use` and `DELETE /api/local-model/models`. */
export type LocalModelLibraryAction = 'use' | 'delete';
/** One model on this Mac. */
export interface LocalModelLibraryRow {
/** The model's name exactly as the runtime lists it (`name:tag`). */
id: string;
/** Its catalogue label, or `id` for a model the catalogue does not know. */
label: string;
/** The context it was checked with. Absent for an `unverified` row. */
context?: number;
/** Its size on disk: what the runtime reports, else what was recorded when it was checked. */
sizeBytes?: number;
/** `sizeBytes` as decimal GB text, no unit. Present exactly when `sizeBytes` is. */
sizeGb?: string;
/** It is the model in use: the one of an `installed` state. */
active: boolean;
/** Codeman checked it (`LocalModelStateFile.library` holds it). */
verified: boolean;
status: LocalModelLibraryStatus;
/** What the server accepts for this row right now. `delete` needs the running runtime. */
actions: LocalModelLibraryAction[];
}
/** The disk use of the model store: the files directly in its blob folder, summed. */
export interface LocalModelStoreView {
bytes: number;
/** `bytes` as decimal GB text, no unit. */
gb: string;
}
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
export interface LocalModelStatus {
hardware: HardwareFacts;
@@ -511,6 +672,27 @@ export interface LocalModelRemoveRequest {
removeModel?: boolean;
}
/** Body of `POST /api/local-model/use`: a checked model of `library` to switch to. Nothing is downloaded. */
export interface LocalModelUseRequest {
/** `LocalModelLibraryRow.id` of a row whose `actions` list `use`. */
modelId: string;
}
/** Body of `DELETE /api/local-model/models`: one model to delete from the store. */
export interface LocalModelDeleteRequest {
/** `LocalModelLibraryRow.id` of a row whose `actions` list `delete`. Never a path. */
modelId: string;
}
/**
* Body of `POST /api/local-model/agent`: a coding agent of `harnesses` to install beside an
* installed model. The click that sends it is the consent to the row's `installCommand`.
*/
export interface LocalModelAgentRequest {
/** `LocalModelHarnessRow.cli` of a row whose `actions` list `install`. */
cli: string;
}
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
export interface LocalModelRefusal {
message: LocalModelInstallMessage;
@@ -522,20 +704,26 @@ export type LocalModelVerbAnswer = LocalModelInstallView;
/**
* Why a verb was refused with 409.
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
* - `already-installed`: install while a model is installed. Remove it first.
* - `already-installed`: install or use of the model that is already the one in use (at the
* same context).
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
* `expectedGb` carry that total, so the window can show it and ask again.
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
* `sessions` names them. Their coding agents were given the runtime's address when they
* started, so they would go on calling a port nothing of ours holds.
* - `sessions-open`: remove, stop, a change of port, an install of another model or a switch
* while live sessions use the endpoint; `sessions` names them. Their coding agents were
* given the runtime's address and the model's name when they started, so they would go on
* calling a port nothing of ours holds, or make the runtime swap models on every request.
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
* - `model-active`: a delete of the model the installation or the current job is about.
* - `runtime-not-running`: a delete while the runtime is down: only the runtime deletes a model.
*/
export type LocalModelConflictReason =
| 'job-running'
| 'already-installed'
| 'size-changed'
| 'sessions-open'
| 'wrong-state';
| 'wrong-state'
| 'model-active'
| 'runtime-not-running';
/** The `data` of a 409 of an install verb. */
export interface LocalModelConflict {
@@ -622,6 +810,14 @@ export interface LocalModelStateFile {
progress?: { completedBytes: number; totalBytes: number };
/** What the runtime reported when the job checked the loaded model. */
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
/**
* Every model that PASSED the check through Codeman and is believed to be in the store, the
* model in use among them. An entry is written when the `verify` step passes and dropped
* when the model is deleted; `remove` drops the whole list. An `installed` file an earlier
* build wrote has none: its one model then counts as the library (`libraryOf()` in
* src/local-model/install-job.ts).
*/
library?: LocalModelLibraryEntry[];
/**
* Present once the `connect` step CLAIMED the connection, which it does before its two
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
@@ -636,3 +832,19 @@ export interface LocalModelStateFile {
/** ISO time of the last write. */
updatedAt?: string;
}
/** One checked model of `LocalModelStateFile.library`. It holds no path. */
export interface LocalModelLibraryEntry {
id: string;
label: string;
/** The context length in tokens it was checked with. */
context: number;
/** The digest `/api/tags` reported when it passed the check. A switch to it must find the same one. */
digest: string;
/** Its size on disk as the runtime reported it then, for a listing while the runtime is stopped. */
bytes?: number;
/** The coding agent of the job that checked it. A switch back takes it when it is installed. */
cli?: { id: string; label: string };
/** ISO time of the check. */
verifiedAt?: string;
}
+28 -2
View File
@@ -207,6 +207,15 @@ export interface LocalHarnessEntry {
gated?: boolean;
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
slowFirstTurn?: boolean;
/**
* A literal with no white space that this harness's composer draws once it takes input, and
* nothing before it does: what a script waits for before it sends the first prompt
* (`GET /api/sessions/:id/wait-output`). Only a mark that was MEASURED on a session started
* on a custom model goes here. Absent: the CLI registry's own
* `capabilities.composerReadyMark` is used when it declares one, and a harness with neither
* has no mark.
*/
readyMark?: string;
}
/**
@@ -248,9 +257,18 @@ export interface HarnessFact {
cli: string;
/** The registry's display label ('OpenCode'). */
label: string;
/** The CLI's binary was found on this machine. */
/**
* The CLI can be started on this machine: the same answer the Run menu gets. For a launcher
* that starts nothing by itself (`discovery.launcherProfile` in the CLI registry) that is its
* binary AND something for it to launch.
*/
installed: boolean;
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
/**
* Not installed, but ONE command makes it startable: the registry has an install command for
* this platform that needs no administrator rights, and the CLI is not a launcher. A launcher
* needs a second install the command does not make (a profile, which is third-party code
* with a consent of its own), so it is never installable from here.
*/
installable: boolean;
/**
* The literal install command the install job would run for this CLI: the STOCK
@@ -259,6 +277,8 @@ export interface HarnessFact {
* (`CandidateHarness.installCommand`).
*/
installCommand?: string;
/** The registry's `capabilities.composerReadyMark`, when the CLI declares one and it holds no white space. */
readyMark?: string;
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
acceptsModelId: (modelId: string) => boolean;
}
@@ -554,6 +574,12 @@ export interface Candidate {
installBytes: number;
/** `installBytes` as decimal GB text, no unit. */
installGb: string;
/**
* Set by the route, never by `recommend()`: this model at this context is a checked model of
* the library on this Mac (`LocalModelInstallView.library`). `active`: it is the model in
* use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded.
*/
library?: 'active' | 'ready';
}
/**
+17 -6
View File
@@ -934,20 +934,31 @@ export interface SessionState {
* from disk, so a restart starts blank. Absent for remote and docker sessions
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
* rediscovered sessions. NOT a confinement root: the file routes stay confined
* to `workingDir`, and a path resolved outside it goes through the attachment
* routes like any other out-of-workspace path.
* to `workingDir`, except for an absolute path inside an
* {@link outsideWorktree}. Any other path resolved outside `workingDir` goes
* through the attachment routes like any other out-of-workspace path.
*/
paneCwd?: string;
/**
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
* only starts its listing here, after `GET .../files` has checked that the real folder still
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
* runtime-only and absent wherever `paneCwd` is, and always a folder strictly below
* `workingDir`: a worktree elsewhere is reported in {@link outsideWorktree}, never here. NOT a
* confinement root; the Files panel only starts its listing here, after `GET .../files` has
* checked that the real folder still lies inside `workingDir`, since a symlink can replace it
* without moving the pane.
*/
liveWorktree?: LiveWorktree;
/**
* The worktree of the session's repository outside `workingDir` that the pane is in NOW, when
* there is one: a linked worktree, or the repository's main worktree for a session started in a
* linked one. It carries `outside: true`, and its `path` is a real path that the repository
* itself vouched for. The file routes serve absolute paths inside it as a second root while it
* still resolves to that same path. Runtime-only, like {@link liveWorktree}; at most one of
* the two fields is set.
*/
outsideWorktree?: LiveWorktree;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
+37 -2
View File
@@ -18,6 +18,15 @@
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
* to 5s on every request, forever.
*
* One thing ends a backoff early: `install.sh` touches `CLI_INSTALLED_MARKER` in
* the data dir when it has installed a CLI, because Run reads this same answer, so
* "Run fails, add the agent, Run again" would otherwise fail twice. The check is
* one `stat` of that file, inside the backoff branch, so a resolved CLI never pays
* for it. It is deliberately not a re-walk of PATH during the backoff: that 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. A CLI installed some other way waits out the backoff as before.
*
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
* short-circuits — IO primitives that were not injected become inert stubs, so
* a suite can never scan the machine's PATH or spawn login shells (the same
@@ -34,6 +43,7 @@ import { execFileSync } from 'node:child_process';
import { accessSync, constants, statSync } from 'node:fs';
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { dataPath } from '../config/instance.js';
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -42,6 +52,12 @@ const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
/**
* The file `install.sh` touches in the data dir once it has installed a CLI (`mark_cli_installed`).
* Its mtime is all that is read; test/install-sh-invariants.test.ts pins the name on both sides.
*/
export const CLI_INSTALLED_MARKER = 'cli-installed';
/** First retry window after a full-chain resolution miss. */
const RESOLVE_RETRY_BASE_MS = 60_000;
/**
@@ -79,6 +95,8 @@ export interface CliResolverHost {
findOnProcessPath(binary: string): string | null;
findInLoginShell(binary: string): string | null;
exists(path: string): boolean;
/** A number that changes when the installer has installed a CLI; 0 when it never did. */
installStamp?(): number;
}
export interface CandidateValidation<T> {
@@ -113,6 +131,8 @@ export interface ProductionCliResolverHostOptions {
shellArgs?: string[];
runCommand?: CliResolverCommandRunner;
isExecutableFile?: (path: string) => boolean;
/** Where the installer's marker is. Defaults to `CLI_INSTALLED_MARKER` in the data dir. */
installMarkerPath?: string;
/**
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
* For tests that exercise `isExecutableRegularFile` against their own temp
@@ -202,6 +222,14 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
return candidate && isExecutableFile(candidate) ? candidate : null;
},
exists: isExecutableFile,
installStamp: () => {
if (inert) return 0;
try {
return statSync(options.installMarkerPath ?? dataPath(CLI_INSTALLED_MARKER)).mtimeMs;
} catch {
return 0;
}
},
};
}
@@ -260,6 +288,8 @@ export function createCliExecutableResolver<T = undefined>(
let lastFailureAt = 0;
/** The invalidation generation the cached state above belongs to. */
let generation = binaryGenerations.get(options.binary) ?? 0;
/** The installer's stamp at the last run of the chain: only a CHANGE is news. */
let installStampSeen = 0;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
@@ -285,8 +315,13 @@ export function createCliExecutableResolver<T = undefined>(
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
// backoff elapses. Without this, every status poll and Run click against
// a missing CLI froze the event loop for the full probe, forever.
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
// a missing CLI froze the event loop for the full probe, forever. A changed
// installer stamp is the one early way out (see the @fileoverview), and buys
// one run of the chain.
const installStamp = host.installStamp?.() ?? 0;
const installedSince = installStamp !== installStampSeen;
if (failures > 0 && !installedSince && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
installStampSeen = installStamp;
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (!cached) {
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview The ONE gate in front of every `git` the server starts.
*
* macOS ships `/usr/bin/git` as a placeholder. On a Mac without the developer tools (Xcode or the
* Command Line Tools), running it, even as `git --version`, makes macOS open its "install the command
* line developer tools" window and exit non-zero. The server used to ask "is git here?" that way on the
* first page load, so a user who had just installed Codeman without git and without an administrator
* password got that window one second after the install finished, and again at every login.
* `install.sh` never runs the placeholder for the same reason.
*
* So on macOS, when `git` on the server's PATH resolves to that placeholder, the developer tools are
* proven first: `xcode-select -p` (a real binary that prints and never opens a window) must name a
* folder that holds a git of its own. Otherwise git counts as absent and is never started.
*
* Every site that starts git calls `gitSpawnAllowed()` first and answers a refusal the way it already
* answers a machine with no git (`gitMissingError()` carries `code: 'ENOENT'` for that).
* `test/git-spawn-gate.test.ts` fails on a git spawn in a file that does not go through this gate.
*
* The decision is pure (`gitSpawnWouldPrompt`, `findOnPath`); the IO around it is two bounded steps, a
* PATH scan and one `xcode-select -p` (a few milliseconds, capped at 3 s), and only on macOS. A "yes" is
* kept for the life of the process; a "no" is asked again after `GIT_GATE_NEGATIVE_TTL_MS`, so
* installing the developer tools later needs no server restart for the git features (the page's own
* "git is available" flag stays memoized per process, like every other availability flag there).
*
* @module utils/git-availability
*/
import { execFileSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { delimiter, isAbsolute, join } from 'node:path';
import { isExecutableRegularFile } from './cli-executable-resolver.js';
/** Apple's placeholder. A real git (Homebrew, MacPorts, Nix) sits elsewhere on the PATH. */
export const APPLE_GIT_PLACEHOLDER = '/usr/bin/git';
const XCODE_SELECT = '/usr/bin/xcode-select';
const XCODE_SELECT_TIMEOUT_MS = 3_000;
/** How long a refusal is kept before the machine is looked at again. */
export const GIT_GATE_NEGATIVE_TTL_MS = 60_000;
/**
* The first `name` on a PATH-style list that `isExecutable` accepts, or null. Pure.
* Empty and relative entries are skipped: they mean "the current directory" of whoever runs the
* command, which this lookup cannot know.
*/
export function findOnPath(
name: string,
pathEnv: string | undefined,
isExecutable: (path: string) => boolean
): string | null {
for (const dir of (pathEnv ?? '').split(delimiter)) {
if (!dir || !isAbsolute(dir)) continue;
const candidate = join(dir, name);
if (isExecutable(candidate)) return candidate;
}
return null;
}
export interface GitGateFacts {
platform: NodeJS.Platform;
/** What `git` resolves to on the server's PATH (symlinks followed), or null when there is none. */
gitPath: string | null;
/** Asked only when it decides the answer, so other machines never pay for it. */
developerToolsPresent: () => boolean;
}
/**
* Would starting `git` open macOS's "install the developer tools" window? Pure.
* Only Apple's placeholder does that, and only while no developer tools are installed. No git on the
* PATH at all is not this function's case: the spawn then fails with ENOENT, which every caller handles.
*/
export function gitSpawnWouldPrompt(facts: GitGateFacts): boolean {
if (facts.platform !== 'darwin') return false;
if (facts.gitPath !== APPLE_GIT_PLACEHOLDER) return false;
return !facts.developerToolsPresent();
}
/**
* The developer folder `xcode-select -p` names must hold a git of its own (`usr/bin/git`, true for both
* Xcode and the Command Line Tools). A selected folder that has since been deleted would otherwise read
* as "tools present" and the placeholder would open its window after all.
*/
export function developerDirHoldsGit(developerDir: string, isExecutable: (path: string) => boolean): boolean {
const dir = developerDir.trim();
if (!dir || !isAbsolute(dir)) return false;
return isExecutable(join(dir, 'usr', 'bin', 'git'));
}
function resolveGitOnPath(): string | null {
const found = findOnPath('git', process.env.PATH, isExecutableRegularFile);
if (!found) return null;
try {
return realpathSync(found);
} catch {
return found;
}
}
function developerToolsPresent(): boolean {
try {
const out = execFileSync(XCODE_SELECT, ['-p'], {
encoding: 'utf8',
timeout: XCODE_SELECT_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
return developerDirHoldsGit(out, isExecutableRegularFile);
} catch {
return false; // exit 2: no developer folder. Never the placeholder's window.
}
}
let allowed: boolean | null = null;
let refusedAt = 0;
/**
* May the server start `git` right now? False only on a Mac whose only git is Apple's placeholder with
* no developer tools behind it. Callers answer false as "git is not installed".
*/
export function gitSpawnAllowed(): boolean {
if (allowed === true) return true;
if (allowed === false && Date.now() - refusedAt < GIT_GATE_NEGATIVE_TTL_MS) return false;
const platform = process.platform;
allowed = !gitSpawnWouldPrompt({
platform,
gitPath: platform === 'darwin' ? resolveGitOnPath() : null,
developerToolsPresent,
});
if (!allowed) refusedAt = Date.now();
return allowed;
}
/** The error a refused spawn stands in for: what Node raises when there is no `git` to start. */
export function gitMissingError(): NodeJS.ErrnoException {
const err: NodeJS.ErrnoException = new Error('spawn git ENOENT');
err.code = 'ENOENT';
err.errno = -2;
err.syscall = 'spawn git';
err.path = 'git';
return err;
}
/** Test seam: forget the remembered answer. */
export function resetGitGateForTests(): void {
allowed = null;
refusedAt = 0;
}
+1 -1
View File
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "RecoLine truncated
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`; never after a switch, a job without a `model` step, which ends on the whole installed screen so the list of models is still there for the next Use or Delete), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE eventLine truncated
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
+110
View File
@@ -0,0 +1,110 @@
/**
* @fileoverview One-time boot migration that lets the 1.50.0 agent skill default reach installs
* that saved App Settings before it.
*
* `agentSkillEnabled` is ON by default since 1.50.0 (an absent key is on, only an explicit false
* is off). Up to 1.49.0 the App Settings switch loaded an absent key as off (`?? false`) and Save
* sends the whole form, so every install whose App Settings were ever saved holds an explicit
* `false` the user may never have chosen, and the new default would never reach it. On the first
* start of a server that has this module, a stored `false` is switched on, exactly once per data
* dir. It cannot tell a deliberate off from the old default; that is accepted and stated as a
* behaviour change in the release notes. An off set after this ran is never touched again.
*
* Why a stored `false` becomes `true` rather than being deleted: deleting it would not reach the
* page. Every browser that saved App Settings keeps the form in its localStorage blob, and
* `loadAppSettingsFromServer()` only overwrites a synced key the server HAS: a key the server
* lacks keeps the device's `false`, the switch loads off, and the next Save of any setting writes
* `false` back (measured with the real page code in the JSDOM harness of
* test/agent-skill-setting-ui.test.ts, which pins the shipped shape). A stored `true` wins on
* every device, including a browser still running a cached settings-ui.js from before the upgrade.
* Every reader treats `true` and an absent key alike (`!== false`).
*
* Why the "done" marker is its own file (`agent-skill-default-migrated.json` in the data dir)
* and not a key in settings.json:
* - it is not a setting, so `GET /api/settings` never returns it, the page never holds it, and
* the page's Save (rebuilt from the form) can neither send it back nor drop it; the `.strict()`
* `SettingsUpdateSchema` never sees it;
* - a PUT or any other settings.json writer cannot erase it, which would re-arm the migration and
* switch a deliberate later off back on;
* - it is not in `CONFIG_BACKUP_FILES` (like `config-backup-id`), so a restore leaves it alone:
* once the migration has run, a restored settings.json, even one from before 1.50.0 holding
* `false`, is what the user asked for and stays as restored.
* A data dir is one instance, and settings.json is one file for the whole server, so in
* multi-user mode this runs once for the server, never per user.
*
* Order: the marker is checked first (present: nothing is read), the settings change goes through
* `mutateSettingsFile()` (serialized, atomic, refuses a file that does not parse), and the marker
* is written only after that resolves. A refused or failed settings write writes no marker, so the
* migration runs again at the next start (after the user fixed the file). Until then it has not
* run, so an off saved after the fix and before that start is switched on with the rest: the same
* "cannot tell" trade as above, in a window that needs an unparseable settings.json at the first
* 1.50.0 start. A crash between the two writes is harmless: the next start finds `true` (nothing
* to change) and writes the marker.
*
* Called once from `WebServer.start()`, before the first settings read and before the server
* listens, so no request path can see the old value.
*/
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { dataPath } from '../config/instance.js';
import { getErrorMessage } from '../types.js';
import { mutateSettingsFile, SettingsWriteRefusedError } from './settings-writer.js';
/** The marker's file name in the data dir. Never in CONFIG_BACKUP_FILES (see the file comment). */
export const AGENT_SKILL_DEFAULT_MARKER = 'agent-skill-default-migrated.json';
/**
* - `switched-on`: settings.json held `agentSkillEnabled: false`; it now holds `true`.
* - `nothing-to-change`: the key was absent or not `false` (or there is no settings.json); nothing was written to it.
* - `already-done`: the marker exists; settings.json was not read.
* - `refused`: settings.json could not be read, parsed or written, or the marker could not be checked.
* Nothing changed and no marker was written, so the next start tries again.
*/
export type AgentSkillDefaultMigration = 'switched-on' | 'nothing-to-change' | 'already-done' | 'refused';
/** The log line of a migration that switched the skill on. */
export const AGENT_SKILL_DEFAULT_MIGRATED_LOG =
'[agent-skill] switched on the off stored before 1.50.0, so the new default applies (turn it off again under App Settings, Agents & CLIs, Codeman Agent Skill; that choice is kept)';
export async function migrateAgentSkillDefault(
markerPath: string = dataPath(AGENT_SKILL_DEFAULT_MARKER)
): Promise<AgentSkillDefaultMigration> {
try {
await fs.lstat(markerPath);
return 'already-done';
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
// Not knowing whether it ran must not switch a deliberate off on a second time.
console.warn(`[agent-skill] default migration skipped: cannot check ${markerPath} (${getErrorMessage(err)})`);
return 'refused';
}
}
let switched = false;
try {
await mutateSettingsFile((current) => {
if (current.agentSkillEnabled !== false) return undefined;
switched = true;
return { ...current, agentSkillEnabled: true };
});
} catch (err) {
// A refusal is already logged by the writer (`[settings] write refused: ...`), with the fix.
const why = err instanceof SettingsWriteRefusedError ? 'settings.json was left as it is' : getErrorMessage(err);
console.warn(`[agent-skill] default migration skipped (${why}); it runs again at the next start`);
return 'refused';
}
try {
await fs.mkdir(dirname(markerPath), { recursive: true });
const marker = { migratedAt: new Date().toISOString(), switchedOn: switched };
await fs.writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
} catch (err) {
// settings.json is already right; without the marker the next start runs again, which
// changes nothing unless the user has switched it off by then.
console.warn(`[agent-skill] could not record the default migration at ${markerPath}: ${getErrorMessage(err)}`);
}
if (switched) console.log(AGENT_SKILL_DEFAULT_MIGRATED_LOG);
return switched ? 'switched-on' : 'nothing-to-change';
}
+15
View File
@@ -373,6 +373,21 @@ export class ApprovalInbox {
return true;
}
/**
* The list route's sweep: `verifyStillAnswerable`, but never before the delay
* `notePrompt` waits out. A frame that shows no dialog proves nothing about a
* dialog Ink has not painted yet, and the working line still on screen then
* would resolve a live prompt, clearing its red alert on every device. The
* list used to run once per page load, which made that rare; a client that
* polls it (the macOS app's logo) would hit it on every new dialog. Each
* call on an older dialog costs one synchronous pane capture.
*/
sweepForList(id: string): boolean {
const item = this.getById(id);
if (item && Date.now() - item.createdAt < STALE_CHECK_DELAY_MS) return true;
return this.verifyStillAnswerable(id);
}
/**
* "This session's pane started moving again": re-verify its pending DIALOG
* item against the screen and resolve it if the dialog is gone.
+1 -1
View File
@@ -17,7 +17,7 @@ export interface ConfigPort {
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
/** Synced `agentSkillEnabled` app setting (default ON, only an explicit false is off); gates per-case agent-skill injection. */
getAgentSkillEnabled(): Promise<boolean>;
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
getWorkspaceHooksEnabled(): Promise<boolean>;
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more