Compare commits

...
Author SHA1 Message Date
Codeman maintainer 39077281d1 chore: ignore a node_modules symlink, not only the directory
`node_modules/` with a trailing slash matches directories only, so a
worktree that links node_modules to a shared install shows the link as
untracked and a plain `git add` commits it. Two PR heads did (#658,
#662), which also broke checking them out next to a real install.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:52:12 +02:00
Codeman maintainer 6e7dee75eb fix(test): build a genuinely short Run menu instead of assuming the stock one fits
The nightly browser suite failed in run-mode-menu-scroll: the short-menu case
opened the stock menu and asserted it needs no scrollbar on a 390x640 phone.
The GitHub Copilot entry made the stock CLI list eleven rows, 562px of content
against a 538px cap, so the menu now scrolls there, which is the cap-and-scroll
behaviour the CSS is written for (and what the other two cases verify). Every
further CLI would have broken the same assumption again.

The case now hides the whole CLI block (the container, since its buttons are
rebuilt from the registry) and also checks the "not stretched" half of its name:
the border-box height must stay under max-height. clientHeight would not do,
since it leaves out the border and a menu stretched to its cap reads just under it.

Visible side effect worth knowing: on a phone about 640px tall the stock Run
dropdown now scrolls by roughly one row. At 900px it fits (564px) unscrolled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 03:08:33 +02:00
Codeman maintainer e63bb65b36 chore: version packages
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 10:03:55 +02:00
Codeman maintainer f744c503c5 Merge branch 'relnext/security-notices': security notices, starting with the OpenCode Console breach
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 10:03:02 +02:00
Codeman maintainer 613fc86d70 fix(ui): touch-sized notice buttons on any coarse pointer, no mid-word break in a Chinese title
A phone in landscape is wider than 599px, so the 44px targets now follow (pointer: coarse) instead of the width. A balanced title broke the zh-CN text inside 入侵; :lang(zh) keeps CJK runs whole and breaks at the title's spaces and colon.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 10:02:49 +02:00
Codeman maintainer 82c34fba99 fix(ui): notice buttons act once, links send no referrer, solo windows defer
- A closing notice card stays in the DOM for its 200 ms fade, and its
  link and "See what's new" buttons still worked there: a double click
  opened the notice twice. close() now makes the card inert and every
  button handler does nothing once it is closing.
- Notice links (and the combined card's "See what's new") open with
  noopener,noreferrer. noopener alone still sent this instance's origin,
  which can be a tailnet name or a tunnel URL, as the Referer. The plain
  update toast is unchanged.
- The https rule is checked again at the button
  (CodemanSecurityNotices.urlOk): a caller handing showSecurityNotice or
  showUpdateNotice an unchecked URL gets no link button.
- A detached session window (/session/:id) is rendered without
  __codemanCliAvailable, so it could never select the OpenCode notice,
  yet it took the update toast and recorded the version, and the
  dashboard then showed the notice as a separate card. While any notice
  is pending, a solo window now shows and records nothing and leaves
  both to the dashboard; with none pending it shows the plain toast as
  before.
- The wiring tests pin the page's own clock (JSDOM gives the window its
  own Date). On the real clock 18 of them would have failed on every push
  from 2027-01-01, when the fixture notice expires. New tests pin the
  once-only close directly (the old double-click comment claimed more
  than it checked), the single open, the sink check, the solo windows,
  the scroll cap and the light-skin ink.
- CLAUDE.md now says the public .json files are served no-cache, the
  exception to the year-long immutable cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 09:30:22 +02:00
Codeman maintainer ee6b115023 fix(ui): keep the security notice card reachable and readable everywhere
- The card is capped to the room below the toast stack's top and
  scrolls inside itself. The stack is fixed and cannot scroll, so on a
  phone in landscape the update toast's buttons sat below the screen
  with only the × left, which records the notice unread. The accent bar
  becomes a background layer, which stays put while the content scrolls.
- Small amber text and the primary button use --notice-ink, darkened a
  step on the four light skins, where --yellow measured 3.9 to 4.4:1 for
  the 10.6px eyebrow (now 5.1 or better, the button label 5.7 or better).
- The update toast that carries a notice frames itself in the accent
  colour and leaves the amber to the warning section inside it.
- The × uses --text-dim (2.4:1 on og before, 5.5 now).
- Phones: every button fills its row (a wrapped Dismiss no longer sits
  stray at the left) and is 44px tall, and the × gets a 44px box.
- The title balances its lines, and the card matches the plain toast's
  420px so a toast stacked under it lines up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 09:30:13 +02:00
Codeman maintainer 5e405bbbb9 fix(ui): shorten the OpenCode notice to what OpenCode said, live from 17 October
The message ran 66 words over five sentences, which pushed the update
toast's buttons off a 360x640 phone. It also repeated the card's own
eyebrow ("Security notice for OpenCode users:"), placed the breached
"legacy system" inside the Console (no source says so), and closed with
an unsourced line about the CLI not being affected. The new text (55
words) says what OpenCode said, including its own reassurance that a
user who got no email was not affected, which matters because the card
goes to every opencode user while about 1.4% of Console users were hit.
The zh-CN text follows it and uses 撤销 for revoking keys.

published moves to 2026-10-17, so the notice is already live in every
timezone when the release lands: a browser still on the 17th would
otherwise take the plain update toast and get the notice later as a
separate card. docs/releasing.md says both things for the next notice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 09:30:13 +02:00
Codeman maintainer 54d0699abd docs: how security notices work, and how to add and retire one
src/web/CLAUDE.md gets the load-bearing rules (hand-maintained file,
strictly-true availability, one surface per notice per load, seen only
on a user action, no CLI-id branching, theme tokens only), and
docs/releasing.md a section on adding a notice, its fields, and letting
`expires` retire it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:59:39 +02:00
Codeman maintainer 668a220abb test(ui): pin security notice selection, placement and wiring
The pure select() and plan() loaded from the shipped constants.js, the
shipped JSON itself (unique ids, https links, real dates, every cli a
stock registry id, a zh-CN title wherever there is an English one), and
the wiring in a JSDOM page: fresh install and same-version reload get the
card, an existing install's update toast carries the notice, the update
toast without a notice matches master's DOM byte for byte, seen only on a
user action, storage blocked shows nothing, base path, zh-CN, and the
card's CSS contract (theme tokens, motion, phone width).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:59:39 +02:00
Codeman maintainer 50bae2d371 feat(ui): security notices, shown only to users of the affected CLI
A hand-maintained src/web/public/security-notices.json carries warnings
about things outside Codeman that its users should act on. The first:
OpenCode's report of unauthorized access to its legacy Console, which
may have exposed provider API keys saved there.

- Pure core in constants.js (CodemanSecurityNotices): select() keeps the
  notices that apply now (inclusive local-day dates, not yet seen, https
  links, malformed entries skipped), and one naming a CLI only when
  window.__codemanCliAvailable[cli] === true. Unknown means not
  installed: a warning about software you do not have is noise. The CLI
  id is data in the JSON; no code branches on it. plan() decides the
  surface, so a load shows each notice once.
- An existing install on a new version gets the notice INSIDE the
  release's update toast: the highlights as before, a warning section,
  and the notice's link next to "See what's new". With no notice the
  update toast is the plain one it always was.
- Everyone else (a fresh install, a same-version reload) gets its own
  card in the toast stack: shield, "Security notice" eyebrow, title,
  message, the link, Dismiss and the ×; amber-to-red accent bar, theme
  tokens only (dark and light skins), full width on phones, an
  opacity/transform entrance and none under prefers-reduced-motion,
  role=alert.
- A notice is recorded as seen only when the user closes it, dismisses it
  or follows its link, never when it appears. Storage blocked shows
  nothing. The notice's own text is textContent and carries its own
  zh-CN, so i18n.js leaves it alone; the card's own copy translates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:59:39 +02:00
Codeman maintainer ada2309721 fix(web): serve the public JSON data files no-cache, not immutable
The static plugin gave every file under public/ a year of `immutable`,
including the JSON the page fetches at runtime: whats-new.json (rewritten
every release), the new security-notices.json (edited by hand) and
manifest.json. None of them is content-hashed or `?v=`-busted, so a
browser could keep last release's copy, and a security notice added in a
later release might never reach it. A `.json` file now revalidates
(`no-cache`); every other static asset keeps its immutable year, and a
route that set its own Cache-Control still wins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:59:39 +02:00
Codeman maintainer 8e6abf4bfd Merge pull request #592: feat(cli-registry): GitHub Copilot CLI as a run mode
GitHub Copilot CLI (`copilot`) becomes a run mode: Run menu, welcome
screen, cron and CLI management once it is installed, tmux-backed like
the other agent CLIs, working/idle and the model read off its footer,
launch options through `copilotConfig` (model, resume, continue, the
multi-user-clamped allowAll), Custom Model Endpoints via Copilot's BYOK
variables, MCP sync of its mcp-config.json, a status probe, and the
`codeman agent` verbs (spawn waits for its composer, read returns its
last answer).

The plan-usage quota row that earlier rounds carried was split out on
review and is not part of this merge.

Merge-time commits on top: the wider COPILOT_* clamp and the zh-CN
README rows (maintainer), the Docker sign-in docs, divider rows inside
an answer kept by `agent read`, route tests for the screen-read
last-response branch, and doc drift (Past Sessions, wheelForward JSDoc,
COPILOT_/OMP_ in the env prefix lists).

Verified before merge on Copilot CLI 1.0.98: injected keystrokes,
bracketed paste and Enter reach the TUI in tmux (github/copilot-cli#4180
does not reproduce), a shell tool call completes inside tmux (#4223 does
not reproduce), and a session driven through this branch answered over
send-and-wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:17:06 +02:00
Codeman maintainer d55267a07c Merge pull request #648: fix(antigravity): show the working indicator while an agy turn runs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:16:43 +02:00
Codeman maintainer 9fc7781442 Merge pull request #647: fix(web): hold the WS pill's queued-bytes suffix until a backlog persists
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:16:43 +02:00
Codeman maintainer 7504a2bc8b Merge pull request #567: fix(cases): only refuse the cases folder and its direct children for a custom-folder case
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:16:42 +02:00
Codeman maintainer d2f6021132 Merge pull request #646: feat(sessions): launch every Claude session with --name set to its tab name
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:16:41 +02:00
Codeman maintainer 66a6319af9 Merge pull request #644: feat(links): make .diff and .patch paths clickable
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:16:41 +02:00
Codeman maintainer 7edea53841 Merge pull request #653: test(uploads): pin the browser's copies of the document list to ALLOWED_DOCUMENT_EXTS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	CLAUDE.md
2026-10-18 08:16:40 +02:00
Codeman maintainer 5481fd599b Merge pull request #651: fix(uploads): say how many files a mixed pick, drop or paste skipped
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	CLAUDE.md
2026-10-18 08:16:10 +02:00
Codeman maintainer 3c16eccd47 Merge pull request #652: fix(uploads): say a refused text upload is not UTF-8 instead of a type mismatch
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:15:52 +02:00
Codeman maintainer b4b4fd0ed8 Merge pull request #654: fix(mobile): find the caret line from the absolute cursor row on a parked viewport
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:15:51 +02:00
Codeman maintainer e290a6eb4b fix(#648): move Antigravity into the screen-checked group in five more docs
docs/api-reference.md (the signal notes and the idle/working rows), and the
wiki's Notifications-And-Approvals, Keeping-Agents-Running and Troubleshooting
pages, plus docs/grok-integration.md, still called Antigravity's working and
idle status unreliable. It now declares a working line and prompt marker like
Codex, OpenCode, Gemini, Pi and OMP, so only Grok stays on generic cues. The
Troubleshooting entry for a session stuck showing busy also says that an
Antigravity session waiting on a shell-command approval shows busy until it is
answered, and the API reference says the same of its idle signal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:14:39 +02:00
Codeman maintainer 9dfbb29a64 fix(#648): anchor antigravity's working line to the start of a row
_probePaneWorking() tests the pattern against the whole pane, so an answer
that quoted the spinner row (an agent explaining this entry: it matches
`⣯  Generating...`) kept the session busy for as long as the text was on
screen. Anchored like gemini's braille branch; every agy capture draws the
spinner at column 0. The prose test now covers the quoted row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:13:34 +02:00
Codeman maintainer 14a9e66547 Merge origin/master into #644: keep diff|patch beside #621's tsv and zip
Master's #621 added tsv and zip to both file-link extension groups in
constants.js while this branch added diff|patch to the same lines. The
resolution keeps both, in master's order with diff|patch after sql where the
PR put it; the rooted and relative groups stay identical, and every upload
document extension plus .diff/.patch still links in both patterns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:12:35 +02:00
Codeman maintainer efe3151377 fix(#647): give the header-stats test app the input queue the constructor creates
Its prototype-only app had no _pendingDeliveries and only passed because the
removed try/catch swallowed the read. The real constructor always creates the
Map.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:11:44 +02:00
Codeman maintainer 52557ec558 test(#654): pin the wrap walk for a soft-wrapped composer on a parked viewport
The parked-viewport test had no wrapped rows and the termination test runs at
baseY 0, so nothing pinned the walk of a multi-row composer through absolute
lines off the bottom. The new case fails on the pre-fix terminal-ui.js
('link' instead of 'history') and passes with the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:10:27 +02:00
Codeman maintainer 8870cfc7dc fix(#646): translate the Pin tab names row and list it in the Settings reference
The new App Settings row had no zh-CN entry, so it read in English in a
Chinese UI, and docs/wiki/Settings-Reference.md did not list it. Adds the
label and description to i18n.js, a row in the Agents & CLIs table, and a
JSDOM test that runs the real translator over the real index.html.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:10:17 +02:00
Codeman maintainer 44f87de9a7 fix(#592): doc drift around Copilot's resume, wheelForward and the env prefixes
- docs/cli-registry.md said Session history resume goes through
  copilotConfig.resumeSessionId / continueSession. Past Sessions sends only
  `continueSession: true` (terminal-ui.js, the modeConfigKey table);
  resumeSessionId is for API callers.
- The wheelForward JSDoc in cli-registry/types.ts read as if the value had
  effect. Nothing reads it (it is in the DECLARED-FOR-LATER list); the
  browser gate is _shouldForwardWheelToApp in terminal-ui.js with its own
  per-CLI rules, which the comment now says.
- The env-prefix lists in the CLAUDE.md Security table and the
  allowedEnvPrefixes() comment in schemas.ts stopped at DEEPSEEK_, missing
  both OMP_ and the new COPILOT_; the Multi-CLI prefix rule missed OMP_.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:09:39 +02:00
Codeman maintainer 35a0000f13 fix(#647): say which header styles show the queued suffix
The suffix is part of the pill's text line, which the Tiles style hides (it
shows WS live) and phones hide too, so the page promised a suffix some users
never see.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:08:46 +02:00
Codeman maintainer 9a57cd4fc4 fix(#647): pin the hold timer's own re-render
The timer test only checked that a handle existed, and the other hold tests
re-rendered by hand, so deleting the re-render from the timer callback left
every test green. A new test waits out the hold for real and asserts the
suffix appears and the timer is spent with no further call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:08:46 +02:00
Codeman maintainer 67ca0954db fix(#647): read the pending queue once per indicator update
The try/catch around _pendingBytes() could not keep _updateConnectionIndicator()
from throwing, because _computeConnectionDescriptor() read the queue again,
unguarded, right after; its test only passed because it stubbed the compute
out. The update now reads the queue once and hands the totals to the compute
(which still reads a fresh queue when called on its own), so the hot input
path walks the queue once per keystroke and ACK instead of twice. The stubbed
test is gone: the COD-133 combinatorial test already pins never-throw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:08:46 +02:00
Codeman maintainer d8850d1b50 fix(#653): export ALLOWED_DOCUMENT_EXTS as ReadonlySet<string>
Now that the set is exported for the parity test, type it read-only like
VIDEO_ATTACHMENT_EXTENSIONS, so an importer cannot add to the live upload
allowlist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:08:24 +02:00
Codeman maintainer e7a6fb5067 fix(#592): pin the screen-capture branch of last-response for copilot
GET /api/sessions/:id/last-response reads a copilot pane through
capturePaneText, since a repainting full-screen TUI streams cursor moves
rather than lines, and nothing pinned that at the route: had the branch
stopped capturing, copilot would quietly have been segmented off the
fused stream again. Three app.inject cases with a stubbed mux: the
captured screen is what gets segmented (the stream text never shows),
a null capture falls back to the buffer, and a line-streaming mode
(opencode) never asks for the screen. The first two fail with the
capture switched off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:08:24 +02:00
Codeman maintainer b67bb076af test(#653): pin one document branch and the pickers' MIME patterns
The reverse classifier check read only the one includes(ext) list, so a
second branch such as `if (ext === 'rtf') return 'document';` passed every
test; the method may now return 'document' from exactly one place. The
accept= checks kept only .ext tokens, so adding text/* to a picker passed;
the non-extension tokens must be exactly image/* and video/*. Both verified
by mutation (each fails the suite).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:08:24 +02:00
Codeman maintainer d170ad2295 fix(#592): a rule inside a Copilot answer stays part of it in agent read
The pane segmenter ended the response block at every full-width rule, so
an answer that draws one (a markdown `---`, a passage quoted between two
rules) read as only its last section: `Summary / ──── / Details` came
back as "Details", and the existing test pinned that by expecting the
quoted passage without its "Here it is:" lead-in.

Inside an open response a rule is now held and written into the text as
`---` once more of the same answer follows, so a rule that only separates
the answer from the footer never trails it. The composer's own two rules
(the last rule/❯/rule triple) are still boundaries. The old test now
expects both parts, and a new one pins a Summary/rule/Details answer as
one block; both fail without the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:07:11 +02:00
Codeman maintainer 2439c087b4 fix(#652): rewrap the Working With Files upload paragraph
The reflow around the new UTF-8 sentence left one 120-character line; the
paragraph is wrapped at about 90 columns like the rest of the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:58 +02:00
Codeman maintainer 8dbcf6e282 fix(#567): route test for a custom folder two levels under the cases directory
Pins the user-facing fix end to end: POST /api/cases with a path two levels
down returns 200, lands in the linked-cases registry and is scaffolded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:39 +02:00
Codeman maintainer af52de4d05 fix(#567): separate refusal messages for the cases folder and a direct child
The one message read "That folder is in the cases folder (...), where it is
already listed as a case" for the cases folder itself too, which is not in
itself and is never listed, and "already listed" is not true for a direct
child that does not exist yet. Two messages now, both still pointing at plain
Create New.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:39 +02:00
Codeman maintainer 9722c5bafe fix(#567): document the cases-folder rule as the folder and its direct children
docs/api-reference.md and the case-path.ts @fileoverview still said a custom
folder must not be, or be inside, the cases directory. After #567 only the
cases directory itself and its direct children are refused (those are what
GET /api/cases lists as local cases); a folder further down is allowed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:39 +02:00
Codeman maintainer 0adce48f1a fix(#651): name _admitPromptAttachments() as the admission entry point in the docs
CLAUDE.md (Prompt uploads) and architecture-invariants still named
_promptAttachKind() as the filter every surface reads. That stays true, but
the surfaces now admit through _admitPromptAttachments(), which is what says
how many files a pick skipped. A new surface that filtered on the classifier
directly would drop files silently.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:38 +02:00
Codeman maintainer 007ea8cfb3 fix(#592): a docker case signs Copilot in inside the container, never by token
Both Docker sections said "or set COPILOT_GITHUB_TOKEN for the session",
which cannot work: a docker quick-start refuses envOverrides
(session-routes.ts), and Copilot's dockerExecEnvNames is empty, so a token
in the server environment is not forwarded into the container either. The
sentence now says to sign in inside the container and why the token route
does not exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:34 +02:00
Codeman maintainer 317a6ce946 fix(#592): mirror the Copilot README changes in README.zh-CN, list COPILOT_* in the allowlist line
The PR names GitHub Copilot CLI throughout README.md (ten CLIs, the
install list, the run-mode table, the multi-CLI prefixes,
the agent-read note and the architecture diagram), and README.zh-CN.md,
which is kept in parity with it since 1a692f58, still said nine CLIs and
never mentioned Copilot. The same spots are translated here (not the plan-usage line: the quota
row was split out of the PR, so neither README names Copilot there).

Both READMEs' "Schema-validated inputs" bullet lists every admitted
env prefix and missed the new COPILOT_* one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:05:09 +02:00
Codeman maintainer 75186a5d97 fix(#592): clamp the COPILOT_* variables that bypass approval, run code or move the token
The COPILOT_ prefix admits every variable Copilot reads, and the 1.0.98
bundle reads far more than `copilot help environment` lists. Only
COPILOT_HOME, COPILOT_ALLOW_ALL and the BYOK trio were privileged, so a
non-granted multi-user owner could still hand back what the allowAll
clamp removed:

- approval: COPILOT_PLAN_THEN_AUTOPILOT, COPILOT_ASSISTED_APPROVAL
- code with no approval row: COPILOT_CLI_DIST_DIR runs <dir>/index.js
  in place of the CLI, the package caches choose the CLI build,
  COPILOT_EDITOR, COPILOT_PROVIDER_API_KEY_COMMAND and
  COPILOT_AHP_HOST_BIN are executed, skills, provider configs and hooks
  are loaded from the named places (the DSH_HOME class)
- token redirects: COPILOT_API_URL and the other API bases receive the
  CLI's sign-in token, which in multi-user mode is the operator's (all
  users share the OS account and ~/.copilot), and the rest of the BYOK
  provider family re-aims a Codeman custom endpoint (the
  DEEPSEEK_BASE_URL / OMP_AUTH_BROKER_URL class)

All are now privilegedEnvKeys. The registry schema capped that list at 8;
the cap is now 48, since a longer deny list only takes more away from
non-granted owners. The test sends one of each class through the real
schema (they all pass it) and the real clamp. The docs say the list is
read from the 1.0.98 bundle and that a variable a later release adds is
not covered until it is listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:04:41 +02:00
Devvyn 76479fdc96 feat(sessions): make pinning every tab name an opt-in setting
Add pinCliSessionName (synced, default OFF), re-read at each spawn and respawn.
Off keeps the #457 behaviour; on pins every non-blank tab name as --name.
Update the docs, JSDoc and changeset that stated the old rule.
2026-10-18 13:58:09 +08:00
Devvyn 1fefeda0dd feat(sessions): launch every Claude session with --name set to its tab name 2026-10-18 13:45:42 +08:00
Codeman maintainer 0ab71d61d1 docs: fix the typecheck command and two stale link labels in CLAUDE.md
The Quick Reference listed `npm run typecheck` as `tsc --noEmit` alone; the
script also type-checks the scripts via `tsc -p config/tsconfig.scripts.json`.
The circuit-breaker and ultracode pointers showed pre-rename anchor names as
their labels while the hrefs already pointed at the current headings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 06:12:38 +02:00
Devvyn ebfa4a01b3 fix(cli-registry): declare Copilot's wheelForward
The entry inherited wheelForward never from agentDefaults() while _shouldForwardWheelToApp forwards Copilot's wheel whenever the CLI tracks the mouse, at any version. New always mode, set on copilot, pinned by a registry test.
2026-10-18 00:47:54 +08:00
Devvyn 24031d5b72 docs(copilot): remote and Docker panes run a bare copilot; model is API or COPILOT_MODEL only
The Remote SSH section claimed the same flags and clamp as a local launch, but defaultRemoteCommandForMode starts a bare copilot (pinned in test/location-overlay-commands.test.ts). Nothing in the UI sets copilotConfig.model, so the model rows now say API callers, or COPILOT_MODEL for a custom endpoint.
2026-10-18 00:47:54 +08:00
Devvyn e37f1f1483 fix(copilot): agent read keeps shell prompts and ruled paragraphs inside an answer
A submitted prompt is only a prompt at column 0 or 1, so an indented shell prompt in a code block stays answer text. Only the last rule, composer row, rule triple is the composer, so a paragraph between two rules in an answer is no longer dropped. A test for each.
2026-10-18 00:47:54 +08:00
Devvyn 6cca5465a9 fix(copilot): a model value must start with a letter or digit
Like resumeSessionId, model follows a flag, so --model --yolo made --yolo its own flag and bypassed the allowAll clamp. New model-plain token pattern, the same rule in the create schema, and golden and schema tests.
2026-10-18 00:47:54 +08:00
Devvyn 68af012845 docs(copilot): drop the plan-usage row from this PR
The Copilot quota chip row moves to its own PR (branch feat/copilot-plan-usage on the fork). This removes its docs, changeset line and CLAUDE.md note.
2026-10-18 00:47:54 +08:00
Devvyn 740474ebca fix(copilot): Free plan usage, tall answers in agent read, page-key fallback, review nits
Review of 8659ba25..be987fbb:

- Usage: a Free account answers premium_interactions {entitlement: 0, remaining: 0,
  percent_remaining: 0, unlimited: false, has_quota: false}, which read as 100% used. The parser now
  returns null when has_quota is false or the entitlement is not positive, with that payload as a
  fixture. Free meters chat (200) and completions (2000) too, so the comment no longer says they
  are unlimited on every plan.
- agent read: rows were skipped until a `❯ <prompt>` row, so an answer taller than the pane
  (prompt scrolled off the top) read as "". With no prompt row AND no banner / trust dialog on
  screen, the rows above the composer are now the tail of the last answer. Text typed in the
  composer (a prompt-shaped row between the two rules) is not a prompt row.
- Page keys: measured on 1.0.95 in tmux that Copilot pages on PageUp/PageDown (three PageUp moved
  lines 109-140 to 010-042, PageDown returned) as well as on SGR wheel reports, so copilot joins
  claude and opencode in _localScrollbackIsHollow(); the stale-tracking-flag fallback the comment
  and docs describe is now true, for the main pane and a TerminalTile (same function), with tests.
- X-GitHub-Api-Version is 2022-11-28, a version GitHub supports (verified against the live
  endpoint); readPlanUsageTelemetryEnabled() is checked before isCopilotAvailable() so a switched-off
  chip skips the resolver probe too.
- Palette label for Copilot in panels-ui.js; stale comments in types/session.ts and stock.ts.
- Docs: Past Sessions continues with --continue (resumeSessionId is for API callers), nothing sets
  continueSession on a respawn, and only a tab name the user set is passed as --name; the usage
  wording for Free; the wheel and mouse-strip paragraphs in CLAUDE.md and
  architecture-invariants; the changeset describes the whole feature.
2026-10-18 00:47:54 +08:00
Devvyn 5b77c271b0 docs(copilot): document GitHub Copilot CLI in full
- New docs/copilot-integration.md in the shape of the other CLI guides: install and identity
  probe, authentication (keychain vs plain-text config.json, COPILOT_GITHUB_TOKEN), what Codeman
  wires up (Run button --yolo and its clamp, model, resume, continue, --name rule), BYOK custom
  endpoints, terminal behaviour (drag selection, wheel and touch), the codeman agent verbs
  (ready mark, read), plan usage, Docker, Remote SSH, what is deliberately not wired, known gaps.
- Agent-CLIs wiki: the eleven-modes table, a GitHub Copilot CLI section, the env prefix row, the
  feature-comparison rows and the status endpoint list.
- README, Home, FAQ, Core Concepts, Docker Cases, Remote SSH, Contributing, The Dashboard and
  Custom Model Endpoints: mode lists and counts; CLAUDE.md: stack line, SessionMode, prefixes,
  resolver list, external-CLI notes and the plan-usage source; api-reference MCP target wording;
  the env allowlist error message names COPILOT_*.
2026-10-18 00:47:53 +08:00
Devvyn 89a82c68d1 fix(copilot): codeman agent read returns the last answer
`/api/sessions/:id/last-response` fell through to the Claude transcript scan for copilot (not in the
external-CLI list), so `codeman agent read` answered empty even with an answer on screen. Copilot
is a repainting full-screen TUI whose stream is absolute cursor moves, so the buffer cannot be cut
into lines either; the route now reads the pane's visible screen (tmux capture-pane) for such
modes and falls back to the buffer where there is no pane.

The segmenter gets a small per-CLI "pane dialect" row instead of another branch in the
Codex-oriented loop: `❯ <prompt>  HH:MM`, `● <answer>` with indented continuation, the cwd /
`Session: N AIC used` row, composer, footer and the `┃` scrollbar gutter are chrome, and
everything before the first prompt (banner, trust dialog) is dropped so a session that has not
been asked anything answers nothing rather than serving its logo.

Fixture is a real Copilot 1.0.95 pane. Tool-call rows are not modelled and read as answer text.
2026-10-18 00:47:47 +08:00
Devvyn d7c7564415 fix(copilot): declare a composer ready mark so codeman agent spawn waits for it
Copilot had no composerReadyMark, so `codeman agent spawn --mode copilot` returned as soon as
the session existed, before the composer could take a prompt (and before folder trust was
answered). The mark is the composer's footer hint, not the glyph: the trust dialog draws
`❯ 1. Yes`, so `❯` would call a worker ready while it still asks for trust. Measured on 1.0.95:
the dialog footer reads `↑/↓ to navigate`, the composer's reads `/ commands · ? help`.
2026-10-18 00:47:47 +08:00
Devvyn d732b46797 fix(copilot): keep a drag a local selection so Auto Copy works
Copilot's TUI turns mouse tracking on itself. Under `strip-mux-only` those DECSETs reached
xterm, which then reported a plain drag to the TUI instead of selecting (only Shift+drag
selected), so Auto Copy and copy-on-select did nothing. This is the failure opencode had and
`strip-mux-and-mouse` was added for: the DECSETs are stripped, a drag is a local selection and
clicks still reach the CLI through the browser's hand-encoded tap (`cliMouseTracking`).

Stripping them also stops xterm from encoding the wheel for the CLI, so
`_shouldForwardWheelToApp` now forwards it as SGR reports while Copilot is tracking the mouse
(the same bytes xterm sent natively, which is what scrolling was verified against). Shift+wheel
stays on local scrollback and a stale flag falls back to PageUp/PageDown.

Tests: the strip flavor for copilot (stripped DECSETs, 3J kept, cliMouseTracking, direct-PTY
untouched) and the wheel gate (forwards while tracking, no other non-claude mode changes).
2026-10-18 00:47:47 +08:00
Devvyn dc87ef724c fix(cli-registry): address review on GitHub Copilot run mode
- Drop GH_TOKEN / GITHUB_TOKEN from env.allowedKeys. The allowlist is one global list with no
  mode context, so they were settable on every session. COPILOT_GITHUB_TOKEN is already admitted
  by the COPILOT_ prefix.
- Declare capabilities.mcpConfig (copilot-json, COPILOT_HOME relocation) so Settings does not
  list Copilot as unsupported while the sync-only target also lists it. The sync-only row is
  skipped while the run mode is enabled.
- Require a leading alphanumeric on the resume id (path-segment in the registry, matching regex in
  the schema): --resume's value is optional, so `--resume --yolo` made --yolo its own flag and
  bypassed the allowAll clamp. Golden cases added; "ids only" wording corrected.
- Remove the unread overlays.credStore line and document signing in inside a Docker container.
- Add the zh-CN "Run GitHub Copilot" string, pin the new declarations in tests and update the
  MCP registry expectations.
2026-10-18 00:47:47 +08:00
Devvyn c5de8383e7 feat(cli-registry): GitHub Copilot CLI as a run mode
Copilot (`copilot`, npm @github/copilot) as a stock CLI entry with the same launch options as the other agent CLIs: model (--model), resume (--resume), continue (--continue) and allow-all (--yolo, clamped off for non-granted multi-user owners). Adds a resolver and GET /api/copilot/status probe, the agent-image package, Run-menu/mobile/logo entries, skill docs and tests.
2026-10-18 00:47:46 +08:00
Devvyn 0085a75c33 fix(antigravity): a pending shell approval reads as working, plus a reattach test
Review round: the footer hint is drawn only at rest, so nothing arms the idle check while the Run this command list is up. The stock.ts comment, wiki, changeset and test now say a pending approval shows as working until answered, and the test pins busy during the confirmation. Adds the antigravity copy of the reattached-pane settle test, and updates the promptGlyph comment and the fixture header.
2026-10-18 00:45:52 +08:00
Devvyn 4a47ead824 fix(antigravity): show the working indicator while an agy turn runs
agy animates an 8-dot braille spinner (⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽) with a label one row above
the composer, none of which is in SPINNER_PATTERN, and the entry declared no
workDetect, so no frame ever marked a tab working.

Declare the working line from the live pane and the footer hint `? for shortcuts`
as the idle-arming mark. The composer's `>` cannot be the glyph: the echoed
prompt starts with it too. Tests use verbatim captures from agy 1.3.3.
2026-10-18 00:45:52 +08:00
Codeman maintainer de99a6d57a docs: tighten the inbox, push and idle-detection wording
Follow-up to the previous commit, after a fact check against the code.

- Answer surfaces: the header bell answers Claude prompts, the phone
  strip only Claude dialogs (and only with the inbox on), and push
  Approve/Deny only a Claude permission prompt (PUSH_EVENT_MAP gives
  actions to permission_prompt alone). README, its Chinese mirror, the
  notifications page and the phone guide now say so.
- Antigravity and Grok Build: after the 3 s launch settle, idle is armed
  only by Claude's prompt marker and working only by a braille spinner,
  so "idle when output stops changing" was wrong. The wiki, the grok
  guide and the API reference now call their status unreliable.
- DeepSeek Harness: its idle and working reports become stop and
  agent_working, which drive respawn, waits and the inbox but not the
  session's own working/idle display. Keeping Agents Running says that,
  and that the respawn path needs the status bridge.
- The wait section of the API reference no longer calls exit the only
  dependable signal for screen-checked modes; the inbox page names how
  Codex and DeepSeek items clear; Core Concepts and Hooks And
  Integrations no longer call DeepSeek "the one" exception; Agent CLIs
  notes that Codex dialogs still raise the red alert; the inbox design
  doc notes the alert-only items.
- Skill: stop and blocked list deepseek, and SKILL.md no longer says
  codex has neither a transcript nor blocked. Mirrored with
  scripts/sync-plugin.mjs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:28:03 +02:00
Codeman maintainer e17eb633c3 docs: correct which CLIs the Approvals Inbox and screen-checked idle cover
The README (and its Chinese mirror) said the Approvals Inbox queues every
pending prompt and answers it from the bell or the phone. It holds prompts
from Claude Code, Codex (screen-read) and DeepSeek Harness only, and only
Claude's are answerable: Codex and DeepSeek items are alerts that say to
answer in the terminal, with no Approve/Deny on the push or the phone strip.
The wiki, the TUI guide and the phone guide now say the same.

Keeping Agents Running, Troubleshooting and the API reference still said
every CLI but Claude, Codex and DeepSeek falls back to output
stabilization. OpenCode, Gemini CLI, Pi and OMP declare
capabilities.workDetect too and get the same screen check; only
Antigravity and Grok Build declare none. Core Concepts now mentions
Codex's screen-read dialogs, and the respawn gotcha notes that DeepSeek's
own end-of-turn report reaches the respawn controller.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:07:10 +02:00
JD 64951eb68c fix(uploads): say a refused text upload is not UTF-8 instead of a type mismatch
An Excel or Notepad CSV/TXT export (UTF-16 with a BOM, or Windows-1252) was refused as "File bytes do not match declared type", which reads like a corrupt file. Name the encoding and tell the user to save it as UTF-8.
2026-10-16 10:56:01 -04:00
JD 7e8ed90e2d test(uploads): pin the browser's copies of the document list to ALLOWED_DOCUMENT_EXTS
The browser cannot import the server's document allowlist, so the classifier, the three pickers' accept lists and the two file-path link patterns each carry a hand-kept copy. A missed copy fails silently: a file greyed out in the picker, an upload dropped client-side, or a pasted path that is no longer a link. Hoist the set to module scope so the test reads the shipped list.
2026-10-16 10:43:24 -04:00
JD 2cfa2783ad fix(uploads): say how many files a mixed pick, drop or paste skipped
A selection mixing a file the server takes with one it refuses dropped the refused one without a word, and the Compose and paste-dialog pickers never toasted even when every file was refused. Every pick, drop and paste filter now goes through one helper that counts what the classifier refused: a partial refusal toasts how many were skipped while the rest upload, a full one keeps the existing Unsupported file type toast, and a clipboard paste that falls back to its text stays quiet.
2026-10-16 10:37:20 -04:00
JD d6d9315fb6 fix(mobile): find the caret line from the absolute cursor row on a parked viewport
xterm's cursorY is relative to baseY, but _tapIsOnCaretLine read it as a screen row. With the viewport parked above the bottom (a tab switch onto a frame with trailing blank rows) a tap on a URL typed in the prompt opened it, and a URL in scrollback on the row cursorY happened to name stayed inert.
2026-10-16 10:37:13 -04:00
Codeman maintainer ce809eac41 chore: version packages
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:23:48 +02:00
Codeman maintainer 25bd792550 docs(changeset): add the multi-user admin-only settings fix
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:23:40 +02:00
Codeman maintainer 973943e942 docs(multi-user): document the settings read, system operations and own picks
- docs/api-reference.md: a non-admin's GET /api/settings omits
  voiceSettings.apiKey, caseOrder and lastUsedCase; a new "System
  operations" table for the admin-only routes and the scoped
  POST /api/auth/revoke; the error-code table now lists FORBIDDEN and the
  other multi-user codes (PASSWORD_CHANGE_REQUIRED, USER_NOT_FOUND,
  USER_EXISTS, LAST_ADMIN).
- docs/wiki/Multi-User-Mode.md: what a regular user reads, that their run
  mode and respawn presets stay theirs on the device, the slow sign-in
  case, and the admin-only system operations.
- docs/multi-user-plan.md: Settings and System ops rows marked implemented.
- CLAUDE.md: the Multi-user mode paragraph names the new pieces and tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:13:35 +02:00
Codeman maintainer 5a297cd3e4 fix(multi-user): read a refused settings write as admin-only in the browser
Two gaps in how a regular user's browser handled the admin-only settings
writes:

- The role comes from GET /api/me, which answers after page load, and on
  a failed request admin-ui.js falls back to a single-user admin. A save
  made then was sent, came back 403, and still said "Settings saved",
  while the refused server-wide change stayed on the device. A 403
  FORBIDDEN on a settings write now ends that save like a known
  non-admin's: synced keys back to the server's value, the one admin-only
  notice, and the page is read-only from then on (_noteSettingsWriteRefusal).
  The display-key list moved into one method (_displaySettingKeySet), so
  that split no longer depends on GET /api/settings having loaded.
- A regular user's run mode and respawn presets were skipped for the
  server but overwritten by the server's on the next page load, while
  "Saved preset" said otherwise. They are now kept as that user's own on
  the device and put back over the server's after the settings load and
  on codeman:me (_rememberOwnSetting, _applyOwnSettings).

Admins and single-user mode are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:12:09 +02:00
Codeman maintainer 24a1c6c427 fix(multi-user): make the server-wide system operations admin-only
docs/multi-user-plan.md lists the system operations as admin-only, but
these routes had no role check, so any signed-in regular user could run
them in multi-user mode:

- POST /api/auth/revoke with an empty body cleared EVERY login session on
  the server, every admin's included. A non-admin now revokes only their
  own logins, and a named sessionToken only when it is one of theirs.
- POST /api/system/update (update and restart the server for everyone),
  POST /api/system/span-displays (a window on the server's desktop),
  POST /api/tunnel/qr/regenerate (clears every user's pending QR login
  code) and POST /api/cleanup-state (drops every user's stale session
  records) now answer 403 FORBIDDEN to a non-admin.
- GET /api/tunnel/info lists only a non-admin's own logins (it showed the
  address and browser of every user's sessions).

Admins and single-user mode are unchanged. The tunnel panel's Regenerate
QR button no longer reports success for a refused request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:11:41 +02:00
Codeman maintainer 33f0bc423f fix(multi-user): keep the admin's Deepgram key out of a non-admin's settings read
GET /api/settings returned the whole settings.json to every signed-in user.
An admin's App Settings save stores the Deepgram key there
(voiceSettings.apiKey), so any regular user could read it, and their
browser copied it into its own voice config and dictated on the admin's
account. caseOrder and lastUsedCase also named the admin's cases.

In multi-user mode a non-admin now reads the file without those three
(settingsForNonAdmin). Admins and single-user mode get the file as it
is. Pinned by route tests and the live multi-user test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:10:59 +02:00
Codeman maintainer 31c03ab62d docs(multi-user): server-wide settings writes are admin-only
api-reference gains a Settings section (GET/PUT /api/settings, the model
config, the case order and PUT /api/config, with the 403 FORBIDDEN a non-admin
gets in multi-user mode). The Multi-User Mode wiki page explains what a regular
user's App Settings save does now, Settings-Reference links it, the plan's
Settings row is marked implemented, and CLAUDE.md notes the gate for the
Multi-user paragraph and for anyone adding a settings write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:18:20 +02:00
Codeman maintainer cb928aefe2 test(multi-user): pin admin-only settings writes, server and browser
- test/routes/system-routes-settings-admin-only.test.ts (app.inject): a
  non-admin PUT /api/settings is 403 FORBIDDEN before the body is parsed, and
  nothing is written, no watcher toggled, no tunnel started, no history limit
  set; the same gate on PUT /api/execution/model-config, PUT /api/config and
  PUT /api/cases/order; admins and single-user mode write as before; GET stays
  readable.
- test/settings-admin-only-multiuser.test.ts (live server, port 0, real
  multi-user auth): settings.json is byte-identical after a non-admin's PUTs,
  and a non-admin's quick-start or Ralph loop no longer records lastUsedCase.
- test/settings-admin-only-ui.test.ts (real index.html in JSDOM): a non-admin
  App Settings save sends no PUT, keeps per-device keys, keeps synced keys at
  the server value, and toasts once only on a real synced change; the
  background writes are skipped; admins still PUT; zh-CN for both toasts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:16:34 +02:00
Codeman maintainer a798c461db fix(multi-user): skip server-wide settings writes for a non-admin browser
With PUT /api/settings admin-only in multi-user mode, a non-admin's page must
not run into the 403. `_settingsReadOnly()` (settings-ui.js) reads the role
from window.__codemanUser; single-user mode and admins are unchanged.

For a non-admin, App Settings Save/Apply still saves the per-device keys to
localStorage exactly as before, but sends neither the settings PUT nor the
model-config PUT, and keeps every synced key at the server's value (the next
page load would put it back anyway). Only when the user really changed a
synced setting do they get one toast that server-wide settings can only be
changed by an admin; a form baseline taken at open keeps an untouched default
that no admin ever stored from reading as a change. The background writes
(run mode, respawn presets and their load-time migration, last used case,
case order, the tunnel toggles) are not sent, and a 403 FORBIDDEN on a tunnel
enable is no longer mistaken for the no-password prompt. zh-CN for both toasts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:12:50 +02:00
Codeman maintainer df0ece6fb4 fix(multi-user): make server-wide settings writes admin-only
In multi-user mode any signed-in non-admin could rewrite the server-wide
~/.codeman/settings.json through PUT /api/settings (feature switches, the
plan-usage collection switch, run mode, respawn presets, the tunnel), although
docs/multi-user-plan.md makes settings writes admin-only.

PUT /api/settings now answers 403 FORBIDDEN for a non-admin before anything is
parsed, written, toggled or started. The other request paths that write a
server-wide setting get the same gate: PUT /api/execution/model-config (the
modelConfig key), PUT /api/config (state.json app config) and
PUT /api/cases/order (the one global caseOrder list). Quick-start and
ralph-loop start no longer record a non-admin's lastUsedCase in settings.json,
where every user could read it. GET /api/settings stays readable by everyone;
single-user mode is unchanged (requireAdmin short-circuits there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:06:05 +02:00
Codeman maintainer b4aed4e485 fix(release): stamp the codeman skill preamble 1.42.0
#642 stamped the preamble with the version it expected to ship in, 1.41.2. The release is 1.42.0, so the stamp would name a version that never existed. Any new value makes cached preambles from the broken version stale, which is what the stamp is for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 11:43:47 +02:00
Michael GrundbergandClaude Opus 5.5 fe762f8dce feat(links): make .diff and .patch paths clickable
An agent that writes a diff for review prints a path such as
/tmp/.../scratchpad/resolution.diff, and that path rendered as plain text
because the link patterns had no diff or patch extension. The attachment
allowlist already serves both as inert text/plain, so only the two link
patterns in constants.js change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:29:44 +02:00
Codeman maintainer ab9fc1b424 docs(changeset): make it 1.42.0 (minor) and add #618
The owner chose a minor release. #618's own changeset is folded into the release changeset like the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 02:40:15 +02:00
Codeman maintainer 15a73dfa05 Merge pull request #618 from opticon454/feat/whats-new
feat: announce each release once with a toast generated from the changelog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 02:39:49 +02:00
Codeman maintainer 123831660a docs(changeset): 1.41.2 release notes
One release changeset for #616, #617, #621, #622, #623, #624, #625, #628, #632, #634, #635 and #642. The changesets carried by #616, #617 and #642 are folded into it and removed (#617's was minor; 1.41.2 is a patch release).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 02:05:14 +02:00
Codeman maintainer 5483811753 Merge pull request #625 from Ark0N/feat/git-worktrees
feat(worktrees): create, open and remove git worktrees of a case

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 02:00:32 +02:00
Codeman maintainer c76600315e Merge pull request #624 from Ark0N/feat/codex-blocked-detection
feat(codex): raise codex approval, question and trust dialogs as BLOCKED

Conflict resolved against #634 in src/session.ts _probePaneWorking():
#624's assignment (a dialog on screen is never work) is kept, and #634's
trust-dialog verdict block (log the hold once, remember the reading)
follows it, before _readWatching().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 02:00:29 +02:00
Codeman maintainer 9aa3083280 Merge pull request #634 from JDProfresh/fix/idle-not-on-trust-dialog
fix(idle): hold idle while the workspace trust dialog is on screen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:55 +02:00
Codeman maintainer eec2895dd7 Merge pull request #635 from JDProfresh/fix/trust-dialog-deferred-read
fix(trust-dialog): re-read a throttled trust-dialog scan instead of dropping it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:55 +02:00
Codeman maintainer d080004436 Merge pull request #617 from opticon454/feat/custom-keybindings
feat(shortcuts): rebindable previous/next session, duplicate-chord refusal, rebinds stay out of the PTY

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:51 +02:00
Codeman maintainer 5eccaca3bc Merge pull request #616 from opticon454/fix/deepseek-install-path
fix(deepseek): find pnpm when installing the terminal profile, and install it with dsh

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:51 +02:00
Codeman maintainer 16bbd260e2 Merge pull request #642 from Ark0N/fix/skill-positional-args
fix(skill): keep the §0 bootstrap intact when Claude Code loads the skill with arguments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:46 +02:00
Codeman maintainer 8084cae0fe Merge pull request #628 from Randalix/fix/codex-submit-paste
fix(tmux): submit text as a bracketed paste so Codex does not swallow Enter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:46 +02:00
Codeman maintainer c5417abd27 Merge pull request #632 from ludovicfourrage/fix/capture-pane-sgr-reset-629
fix(tmux): reset captured SGR before live output resumes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:46 +02:00
Codeman maintainer 6e4181a2e6 Merge pull request #622 from JDProfresh/fix/attach-video-containers
fix(uploads): refuse an unsupported video before uploading it

Conflict resolved against #621 in src/web/public/image-input.js (the
_promptAttachKind doc comment only): #621's comment is kept with #622's
clause that a video MIME counts only for the containers the server
admits; #622's video MIME check and #621's document branch both stay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:42 +02:00
Codeman maintainer 0866090bc3 Merge pull request #621 from JDProfresh/feat/prompt-upload-documents
feat(uploads): prompt uploads take documents

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:23 +02:00
Codeman maintainer 48637e9920 Merge pull request #623 from JDProfresh/fix/prompt-cache-report-bounds
fix(telemetry): gate the prompt cache like the model report, and keep the first 20 miss causes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:59:23 +02:00
Codeman maintainer 4b73ec8417 fix(#624): read codex's trust prompt as BLOCKED when it ends in esc back
On codex-cli 0.162.0, with 0.163.0 released, every launch first shows the
"Update available" prompt, and choosing Skip leads to a variant of the
workspace trust prompt: "2. Back to Agent Command Center" over the key-hint
row "enter continue · esc back". The pattern only accepted cancel,
interrupt, quit, answer or all as the closing word, so a fresh git repo
launched from Codeman sat on that trust prompt reading idle, with no red
alert and no blocked signal (measured live on a throwaway instance).

Adding "back" is safe because the hint row must still START with one of
the anchored hints ("enter continue" here): the popups the user opens at
the keyboard (/model, /permissions, /review, Implement this plan?) end in
"esc back" too, but their row starts with "enter select", so they stay
unmatched, as every existing negative fixture confirms. The new fixture is
the live capture with the scratch path replaced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:42:40 +02:00
Codeman maintainer 688bdfb970 fix(#616): install pnpm only when none is on PATH
The DeepSeek install command asked npm for dsh and pnpm in one go. On a
machine that already has pnpm through corepack (`corepack enable` puts a
pnpm shim in node's own bin directory, which is npm's global bin for nvm
and Homebrew installs), `npm install -g pnpm` refuses to overwrite that
shim with EEXIST, and because both packages were in the same command the
whole install failed: dsh was not installed either. Measured with npm 10
against a scratch prefix holding a corepack-style shim. On master the same
machine installed dsh fine and the profile install used the existing pnpm.

The command is now `npm install -g @deepseek-ai/dsh && (command -v pnpm
>/dev/null || npm install -g pnpm)`, so pnpm is installed only when the
install environment has none, and a failed dsh install still fails the
command. The two hints that embed it follow (the launch-table drift test
requires that), and the catalogue is regenerated; install.sh withholds
the command for a launcher-only CLI, so its block is unchanged.

The test now runs the real stock string through /bin/sh with a fake npm
on a PATH it controls, for the three cases: no pnpm, an existing pnpm,
and a failing dsh install.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:37:41 +02:00
Codeman maintainer bea9865cff fix(#625): never remove a worktree that is, or holds, a Docker case workspace
The Docker rule was applied to the CASE folder and its common git dir
only. A Docker case can be linked to any host folder, including a managed
worktree (`<repo>/.claude/worktrees/<name>`), which is a natural setup for
running one of the parallel agents in a container. Removing that worktree
from the local case's dialog, or through Close Session's "Kill & remove
worktree", ran `git worktree remove`, which runs a git status INSIDE the
tree (container-written files, checkout filters on this machine) and then
deletes the whole tree, ignored files included, while the container still
mounts it. Reproduced on an isolated instance: DELETE answered 200 and the
Docker case's workspace was gone.

That contradicts the invariant the PR itself documents in CLAUDE.md ("git
never runs ... at or inside a Docker workspace"). removeCaseWorktree now
refuses (400) a present worktree that is, sits inside, or holds a Docker
case workspace, before any git runs; the DELETE route passes the Docker
roots it already reads for the case check. A worktree whose folder is gone
is unaffected (git only drops its record there). Unit and route tests
cover both directions and an unrelated Docker workspace; docs updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:36:45 +02:00
Codeman maintainer a61db218ee fix(#618): translate the release toast for zh-CN
The toast every user sees once per release had no zh-CN entries, so a
Chinese interface showed it, button included, in English. The button
gets an entry and the frame a translateDynamic pattern that keeps the
version, the "(+N more)" count and the highlights (those come from
CHANGELOG.md and stay English). Tested through the real i18n.js in JSDOM,
plus a pin on the strings settings-ui.js builds, which the pattern
depends on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:28:54 +02:00
Codeman maintainer 71439c525b fix(#618): document whats-new.json in the release procedure
docs/releasing.md step 3 lists everything `npm run version-packages`
writes, and the COM commit stages explicit paths, so the new generated
file has to be named there: committed with the release, the version
check in test/whats-new.test.ts that fails a hand bump (with the two
commands that regenerate it), and that a release without bold lead-ins
ships an empty list and no toast.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:27:54 +02:00
Codeman maintainer f2154a5d33 fix(#618): read lead-ins behind the changesets commit prefix, skip group labels
`changeset version` prefixes the first line of every committed changeset
with its commit ("- d18deed: **Lead.** text"), so a changeset that opens
with its bold lead-in, which is the shape the release notes ask for, was
never matched in a changesets-bot release. The pattern now allows that
prefix.

The release notes also put group labels in bold at the start of a line:
"**Fixes.**" and "**Fixes applied while landing.**" (1.41.1, 1.41.0,
1.35.0, 1.33.3). Those were taken as highlights, so a release with few
features announced "... · Fixes applied while landing". They are skipped
like the Thanks block. Tests cover both, and a release with no lead-ins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:27:26 +02:00
Codeman maintainer 02ea9251f8 fix(#618): keep the release commit green when a release has no bold lead-ins
The shipped-json test asserted at least one highlight. Many releases are
written without bold lead-ins (1.32.1, 1.29.1 and 1.24.7 here, and every
changesets-bot release whose changesets are plain sentences, this PR's own
changeset included), and the generator handles that on purpose: it warns
and writes an empty list, and the client then stays quiet. Running
`npm run version-packages` in a scratch copy with only this PR's changeset
produced `{"version":"1.42.0","highlights":[]}` and turned the gate red
on exactly the release commit. The version check, which is what keeps a
hand bump from shipping the previous release's notes, stays.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:27:26 +02:00
Codeman maintainer 9117b183a1 fix(#621): correct three upload doc claims
The wiki said .codeman-uploads/ is the one path "a local agent, a container
and a remote host all see the same way", two sentences after saying uploads
are refused for remote (SSH) sessions. Only a local agent and a Docker case
(workspace bind-mounted at the same absolute path) share it, which is why the
route refuses remote sessions; say that.

The same paragraph said every image goes in after a re-encode on the device,
but _normalizeImageForUpload passes a GIF through untouched to keep its
frames.

CLAUDE.md and a test comment described the image fallback as behaving
"as before 1.36", a release that never existed (1.35.0 was followed by
1.40.0). It means the image path as it was before this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:23:28 +02:00
Codeman maintainer b6218c1490 docs(readme): link the Codeman Skill wiki page from both READMEs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 00:46:29 +02:00
Codeman maintainer 4eef417da6 fix(skill): keep the §0 bootstrap intact when Claude Code loads the skill with arguments
Claude Code rewrites a skill body that it loads with arguments: every bare
$1, $2 ... becomes the matching word of the request (0-based). Nine lines of
the agent preamble used bare positional parameters, so an agent that pasted
the §0 bootstrap (every session without a pre-seeded preamble: Agent Skill
injection off, the default, or a remote or Docker session) wrote helpers
that queried session "5" with timeout=claude and looked up "5=" instead of
CODEMAN_PASSWORD= in the .env, sending every call unauthenticated.

- $1/$2 -> ${1}/${2} in preamble.sh and the identical §0 heredoc in
  SKILL.md; same bash, and Claude Code's pattern (\$\d+(?!\w)) skips it.
- CODEMAN_PREAMBLE 1.33.4 -> 1.41.2, so a cache file written by the broken
  bootstrap (which carries the old stamp) is rewritten on the next run.
- A comment at the top of the preamble states the rule.
- New test: Claude Code 2.1.300's substitution applied to SKILL.md with
  sample arguments must leave the body, and the pasted heredoc, unchanged.
  It fails on the old file with the exact sessions/5 and timeout=claude lines.
- plugins/codeman re-synced; patch changeset.

Verified with real Claude Code 2.1.300 (the delivered heredoc is now
byte-identical to preamble.sh; before, 9 lines differed) and by running the
pasted helpers against a local recording server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 19:33:09 +02:00
Codeman maintainer df2bdc8a9e docs(wiki): add a Codeman Skill page with the skill GIFs
The skill only had a section inside "Driving Codeman From An Agent". It now
has its own page, docs/wiki/Codeman-Skill.md, built around three real runs:

- five Claude Code workers powering on in the tile grid (2026-10-15)
- eight worker tabs with lineage lines back to the lead (2026-10-15)
- a DeepSeek Harness worker and a Claude Code worker from one prompt
  (2026-10-10)

plus every install route, prompts that work as written, a run start to
finish, which workers it can drive, what it will and will not do on its
own, the Codex notes and what is in the skill. The page says that the grid
recording put each worker on the grid as it started, since sessions an
agent starts do not join the grid by themselves.

The agent page keeps a short skill section (the heading anchor stays) and
links to the new page; the sidebar, Home, FAQ and Settings Reference link
to it too. The FAQ answer no longer says the skill is enabled per case only.
docs/media-assets.md records the new embeds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 19:10:38 +02:00
Codeman maintainer 11e8ac0b2d docs(readme): add two codeman skill GIFs (5 tiles, 8 tabs with lineage)
Two live captures of a Claude Code lead driving the codeman skill, added to
the agent skill section of both READMEs:

- Step 2: `use codeman to start 5 claude workers, one per TODO item`, five
  Claude Code workers power on as tiles in the grid (1120x630, 17 s, 10 MB).
- Step 3: the same prompt with 8 workers in the normal view, eight tabs
  power on with lineage lines back to the lead, then a click into a live
  worker (1120x630, 16 s, 8 MB).

Real sessions only, cut from the same raw takes as the promo videos; the
time-lapse stretches are labelled on screen. CRT beats play at 50 fps, the
busy time-lapses at 25 fps, one global palette, no lossy pass. Privacy OCR
on every second frame: clean. docs/media-assets.md lists both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 18:17:34 +02:00
JD 2a5f5b30be fix(idle): hold idle while the workspace trust dialog is on screen
The dialog's own prompt glyph armed idle confirmation and the quiet dialog passed it, so a fresh case reported idle about 4 s in. An agent that waited for idle and then prompted typed into the dialog, and its Enter confirmed No, exit and killed the pane.
2026-10-14 22:16:49 -04:00
JD 892c2fc4df fix(trust-dialog): re-read a throttled trust-dialog scan instead of dropping it
The scan must schedule its own next read rather than wait for PTY output, but a call inside the throttle was simply dropped. The dialog's own paint is often the last output a launching pane produces and lands inside that throttle, so the session sat on the dialog with no keystroke ever sent. A stopped session no longer scans, since the kill's own output kept it answering a dialog in a dead pane.
2026-10-14 22:03:15 -04:00
Devvyn d18deed51d feat: announce each release once with a toast generated from the changelog 2026-10-15 04:22:26 +08:00
RandalixandClaude Opus 5.5 7fd76f06c3 fix(tmux): type a single-character submit so a dialog answer selects
Claude Code's and Codex's selection dialogs ignore a bracketed paste, so
after the previous commit a pasted `3` + Enter confirmed the highlighted
option instead of option 3. sendInput() now types a single character with
`send-keys -l` as master did (submitsAsKeys()). One character cannot form
the burst Codex reads as a paste, so the Codex fix is unaffected.

The trailing-`;` escape moves into escapeTmuxTrailingSemicolon() and now
also covers every `send-keys -l` call in sendInput() and
sendInputToPane(); tmux 3.5a types `x;` for `send-keys -l 'x\;'`.

Docs: the @fileoverview lines and orchestrator-loop-architecture.md still
described `send-keys -l`; CLAUDE.md, architecture-invariants.md and
cron-guide.md name the single-character exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 21:23:58 +02:00
Ludo Fourrage 96f6cdea8c fix(tmux): reset capture styles before live output resumes 2026-10-14 18:07:32 +00:00
RandalixandClaude Opus 5.5 344edc942b fix(tmux): submit text as a bracketed paste so Codex does not swallow Enter
sendInput() typed the text of a text+Enter pair with `send-keys -l` and
sent Enter 50 ms later. Codex detects an unmarked paste from a fast
character burst and folds an Enter inside that window into the paste as a
newline, so the prompt stays in the composer unsubmitted. Measured with
codex-cli 0.162.1 in a throwaway tmux pane: 1505 chars stuck 3/3 as
`[Pasted Content 1506 chars]`, a 98-char line stuck 1/4. SubmitVerifier
re-presses Enter for the short case, but it cannot match the long one: the
composer shows the placeholder instead of the prompt's head.

The text now goes in via set-buffer + `paste-buffer -p -d` (markers only
when the pane requested bracketed paste), followed by the separate Enter as
before. Same pane, same texts: 4/4 and 3/3 submitted. Each send gets its
own named buffer; a paste that fails (pane or server gone) deletes it,
since tmux never trims named buffers on its own.

Also escapes a trailing `;` in the text, which tmux treats as a command
separator (`send-keys -l 'x;'` typed `x`, a lone `;` typed nothing).

sendInputToPane() has no callers in src/ and is left as it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 10:20:01 +02:00
Codeman maintainer 28cbbad591 fix(worktrees): the injected agent skill no longer blocks removal
With Codeman Agent Skill on (agentSkillEnabled), every session create
writes the skill into the folder the session runs in, so a session in a
worktree leaves an untracked .claude/skills/codeman/ (claude) or
.agents/skills/codeman/ (codex, gemini and the rest) behind. git then
refuses `git worktree remove` for that worktree forever, and an agent
running `git add -A` there would commit Codeman's skill into its branch
(reproduced: the worktree's status showed "?? .agents/" and
"?? .claude/").

A create now excludes <skills dir>/codeman/ next to the hooks file, for
every skills folder the registry names (capabilities.agentSkillDir,
read at call time by the route and passed in as data, so no CLI id is
branched on). Like the other lines it is only added when git does not
already ignore the path, and only to the repository's local
info/exclude.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:37:48 +02:00
Codeman maintainer 64fea4ff84 fix(worktrees): a worktree whose folder was deleted can still be removed
When a managed worktree's folder is deleted by hand (a user in a file
manager, an agent's rm -rf), git keeps its record and lists it as
prunable. Codeman then had no way out: the dialog showed the row as
"missing" with no action, DELETE answered "No worktree named ...", and
the name could never be used again (the branch still existed, and with
another branch git refused "a missing but already registered
worktree"). Reproduced on git 2.43; git's own `git worktree remove` on
the missing path drops the record with exit 0.

Removal (only removal, never a session start) now accepts a worktree
whose folder is gone when git lists it at exactly the managed path, as
prunable or locked, and no component on the way is a symlink or a file.
The live-session and lock refusals still apply. The dialog offers
Remove... (not Open) on such a row, with a note saying what it does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:34:49 +02:00
Codeman maintainer 0ce6e09725 fix(worktrees): a session spelled through the case's symlink blocks removal
The removal guard and the per-worktree session count compared each
session's workingDir lexically against the worktree paths git lists,
which are real paths. POST /api/sessions stores workingDir as it was
spelled, so a session started in a worktree of a linked case whose
folder is a symlink (a repository on another disk, macOS /tmp, a
symlinked cases folder), named through that link, counted for no
worktree, and DELETE /api/cases/:name/worktrees/:worktree removed the
folder under the running agent (reproduced: 200 instead of 409).

Each session folder under the case path is now respelled onto the
repository's real top level before the comparison (respellUnder, pure
and lexical, so no filesystem call per session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:31:37 +02:00
Codeman maintainer 3952963bad docs(worktrees): API reference, wiki page and the invariants behind them
Documents the worktree endpoints and quick-start's worktree field in
docs/api-reference.md, adds a Git Worktrees page to the wiki (sidebar,
Home index, a pointer from Core Concepts where sessions sharing one
case folder is explained), and records the rules that keep the feature
safe in docs/architecture-invariants.md with a short CLAUDE.md pointer:
only the managed .claude/worktrees path is ever touched, git never runs
over a Docker workspace, removal is never forced and branch delete is
-d only, and sessions start through the existing create paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:10 +02:00
Codeman maintainer 8cffcf4be5 feat(worktrees): worktree dialog, launch in a worktree, remove on close
The worktree API needs a front door that works from the place cases are
already picked. The toolbar case picker gains a third action row,
"Git worktrees…" (the phone case sheet gets a Worktrees button next to
Create New Case; no header button anywhere), opening one dialog per case:

- the repository's worktrees, with the managed ones (.claude/worktrees)
  offering Open (start another session in it) and Remove…, an inline
  confirmation with "Also delete its branch" (git branch -d) whose git
  refusal is shown verbatim through textContent;
- a New worktree form (name, optional branch and start point) and a
  "Start sessions with" picker over every enabled, installed run mode,
  built from the registry catalogue as data, never an id branch.

Launching goes through run() with options.worktree and the same temporary
_runMode swap the custom-model entries use, so every mode keeps its own
path: claude and shell create through POST /api/sessions with the
worktree as workingDir, every other CLI through quick-start's worktree
field. Tabs are named w<n>-<worktree> and the Run button's mode is left
as it was.

Close Session offers "Kill & remove worktree" for a session that runs in
a Codeman-managed worktree; the removal waits for the server to confirm
the session is gone (closeSession now resolves true/false), and a dirty
tree keeps the worktree with git's reason in a toast that opens the
dialog. zh-CN strings and toast patterns for all of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:06 +02:00
Codeman maintainer 517e12fe1d feat(worktrees): create, list and remove git worktrees of a local case
Sessions in one case share its folder, so several agents on one repository
edit, build and commit in the same checkout and step on each other. A case
whose folder is a git repository can now keep worktrees under
<repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>),
which is Claude Code's own --worktree layout (measured in Claude Code
2.1.296) and the one Codeman's worktree pill already recognizes.

New module src/git-worktrees.ts (pure name/branch gates, worktree list
parsing, exclude text; IO through an injectable runner) and routes
GET/POST /api/cases/:name/worktrees and
DELETE /api/cases/:name/worktrees/:worktree:

- git runs as argv with -- before paths, non-interactive env, process
  group timeouts, --no-optional-locks on reads; names are gated before
  git and confirmed with git check-ref-format --branch.
- Only <repo>/.claude/worktrees/<valid-name> is created or removed; every
  path component is lstat'ed and realpath-checked, so a symlinked .claude
  cannot redirect a create or remove outside the repository.
- /.claude/worktrees/ (and the per-workspace hooks file Codeman writes,
  which otherwise blocks git worktree remove) go to the repository's
  local info/exclude, never a tracked .gitignore.
- Removal is git worktree remove WITHOUT --force, refused while a live
  session runs inside; branch delete is git branch -d, never -D.
- Docker and remote cases are refused with a 400, and git never runs for
  a case at or inside a Docker case workspace.

Starting a session reuses the existing create paths: quick-start takes
a worktree name (validated, local cases only) and runs hooks, the skill,
the model override and the session itself in the worktree;
POST /api/sessions takes the worktree as workingDir. The unified session
list names the worktree of a live session from its folder layout, so a
codex or shell session in one gets the worktree pill too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:00 +02:00
JD 8bf581cc55 fix(telemetry): keep the first 20 miss causes instead of refusing the post
last_miss_cause.causes was z.array(z.unknown()).max(20), so a statusline
payload carrying more than 20 causes failed validation and lost the
whole post: plan usage, model, cache and the footer with it. Truncate
to the first 20 with a preprocess that slices before the array
validates, so an over-long array costs one bounded slice rather than
per-element work across every entry.
2026-10-13 20:45:13 -04:00
JD 4091378e15 fix(telemetry): store the prompt cache only for CLIs whose statusline reports it
The status-telemetry route gated the model report on
capabilities.statusLineTelemetry but stored the prompt-cache report for
any session mode, even though both ride the same statusline payload the
exporter POSTs. Gate the prompt-cache store on the same capability so a
CLI with no statusline exporter no longer carries prompt-cache state it
never actually reported.
2026-10-13 20:45:13 -04:00
JD 8e193c4228 fix(uploads): refuse an unsupported video before uploading it
_promptAttachKind returned 'video' for any video/* MIME, so an Android
3gp or an mkv streamed up to 1GB before the ?kind=video route refused it
with a 400. Mirror the server's own video-container set so those are
filtered client-side and get the existing unsupported-file toast at once.

Follows the maintainer's follow-up suggestion on #606.
2026-10-13 20:34:56 -04:00
JD 244d9a49f1 feat(uploads): prompt uploads take documents
The Attach button, the Compose and paste-dialog pickers, terminal drop and the
clipboard now take documents beside images and videos: the office formats
pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml.

A document is admitted by its name's extension (the MIME a phone attaches to a
.md is anyone's guess), buffered under the same 50MB image cap, and judged on
its bytes by the same fail-closed sniffer: a magic-number signature for the
binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized
stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf.

sh/py/js/html are refused by name with a clear 400. That is a scope choice, not
a security property: file-raw already serves non-media text inert, so this keeps
the server from storing what it would serve or an agent could run, and html and
svg are the ones deliberately kept out. One client classifier (_promptAttachKind)
now returns image, video or document, and the server's dispatch mirrors it: an
image MIME wins whatever the name says, a document extension takes the document
branch, and a foreign extension with no image MIME is refused only after the
image sniff fails, so nothing the image path accepted by its bytes is lost.
2026-10-13 20:31:39 -04:00
Devvyn 3776bbd431 fix(shortcuts): address review on rebinding (gate holes, re-enable/Reset duplicates, labels, tests)
- isUserBoundShortcutEvent skips toggleSessionSidebar (it has its own gate) and an
  entry re-captured onto its own default, so ^L still reaches the CLI.
- Re-enabling a shortcut and Reset run the duplicate check and refuse when the
  chord was taken meanwhile; Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved with
  Alt+1-9, and the Alt-digit check no longer excludes Cmd.
- Alt+[ / Alt+] keep their Alt/Option+[ labels via displayBindings.
- Behavioural tests (vm harness) replace the source-string test.
- Docs: wiki Rebinding / Why-chords-behave-oddly, architecture-invariants note.
- Small: _shortcutActions indentation, stale Alt-block comment, typeof check
  instead of Object.hasOwn, full-width parentheses in zh-CN.
2026-10-14 02:47:58 +08:00
Codeman maintainer d8cafc6d0d docs(dashboard): describe the header transport readout and when it shows queued bytes 2026-10-11 12:53:42 +08:00
Codeman maintainer 764910baca fix(web): hold the WS pill's queued-bytes suffix until a backlog persists
A paste, an IME commit or a fast word sits above the 4-byte hint threshold for a few
milliseconds until its ACK lands, and each one widened the header pill (`WS · 12B queued`) and
moved the gauges beside it. The suffix now appears only after the backlog has stayed above the
threshold for 1.2s; a drain forgets the burst and a timer re-renders when the hold elapses.
2026-10-11 11:37:09 +08:00
Devvyn b7be8db80b fix(deepseek): find pnpm when installing the terminal profile, and install it with dsh 2026-10-10 14:27:54 +08:00
Devvyn 1aeabdfd55 feat(shortcuts): rebindable previous/next session, duplicate-chord refusal, rebinds stay out of the PTY 2026-10-10 13:50:06 +08:00
Devvyn 2f8e0377ff fix(cases): only refuse the cases folder and its direct children for a custom-folder case
blockedReason() refused every path below a cases directory, with the reason that it is
"already listed as a local case". GET /api/cases lists only the direct children of the cases
directory, so only those can be registered twice. With CODEMAN_CASES_PATH set to a broad root
such as /mnt/user/Scripts, the rule blocked almost the whole tree, including folders that are
already linked cases. Refuse the cases directory itself and its direct children only, and name
the cases directory in the message.
2026-10-10 13:50:04 +08:00
187 changed files with 12579 additions and 820 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 only inside a Codeman-managed session.",
"version": "1.41.1",
"version": "1.43.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+3 -2
View File
@@ -8,8 +8,9 @@ DECISIONS.md
# Written by install.sh into end-user clones when setup finishes
.install-complete
# Dependencies
node_modules/
# Dependencies. No trailing slash: `node_modules/` matches only a directory, so a
# worktree's `node_modules` symlink to a shared install got committed (#658, #662).
node_modules
# Build output
dist/
+64
View File
@@ -1,5 +1,69 @@
# aicodeman
## 1.43.0
### Minor Changes
- ### Thanks
- @opticon454 for five PRs: GitHub Copilot CLI as a run mode (#592), an Antigravity tab that shows when it is working (#648, reported in #639), a WS pill that no longer flickers on every paste (#647, reported in #638), custom-folder cases anywhere below a broad cases folder (#567, reported in #637), and the opt-in "Pin tab names on the CLI" setting (#646). Also for filing #637 to #641 with the cause already worked out, and for measuring Copilot and `agy` live instead of guessing.
- @JDProfresh for four follow-ups to the document uploads in #621: a mixed pick, drop or paste that says how many files it skipped (#651), a text upload refused as "not UTF-8" instead of a type mismatch (#652), a parity test that keeps the browser's copies of the document list honest (#653), and link taps on a phone that land on the right row after a tab switch (#654).
- @irisitymichaelgrundberg for clickable `.diff` and `.patch` paths (#644).
**Security notices for the CLIs you run.** Codeman can now warn you about a security incident in an agent CLI it runs, and it does so only on machines where that CLI is installed. After an update the warning sits inside the "Codeman updated" note, with a button to the vendor's own notice next to **See what's new**; on a new install, or if you missed it, it comes as a card of its own. It stays until you close it or open the link, and expires on its own. The first one is about OpenCode: on 17 October OpenCode reported unauthorized access to a legacy system, and provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.
**GitHub Copilot CLI is a run mode (#592).** Install `copilot` (`npm install -g @github/copilot`) and it appears in the Run menu, on the welcome screen, in cron and in CLI management. It runs in tmux like the other agent CLIs, shows working and idle and the model it reads off its own footer, takes the usual launch options (`copilotConfig`: model, resume, continue, and `allowAll`, which is clamped off for non-granted multi-user owners), works with Custom Model Endpoints through Copilot's BYOK variables, joins MCP sync, and works with `codeman agent spawn`, `send` and `read`. A plain drag selects text, so Auto Copy works, while the wheel and phone swipes scroll its transcript. Copilot's folder-trust dialog still needs one Enter the first time a folder is used.
**Pin tab names on the CLI (#646, optional).** A new App Settings switch, off by default, launches every Claude session with `--name <tab name>`, not only the ones you renamed, so Claude Code's prompt-box label, `/resume` entry and remote session list match your tabs. The cost, which the setting spells out: Claude stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. It applies from the next session start or respawn.
**Antigravity tabs show when they are working (#648).** An `agy` turn now marks its tab working and a finished turn reads as idle again. A shell command waiting for your approval keeps showing as working until you answer it.
**Fixes.**
- **Custom-folder cases below the cases folder (#567).** Create New with a custom folder refused every folder anywhere below the cases folder. Now only the cases folder itself and its direct children are refused, since those are the only ones listed as local cases, so a broad `CODEMAN_CASES_PATH` such as `/mnt/user/Scripts` no longer blocks the whole tree. The refusal now says which of the two it hit.
- **A steady WS pill (#647).** The ` · NB queued` suffix appears only once a backlog has lasted over a second, instead of flashing on every paste, IME commit or fast burst.
- **Clickable diff and patch files (#644).** A `.diff` or `.patch` path is now a link in the terminal and the response viewer, so an agent's review diff opens in one click.
- **Mixed uploads say what they skipped (#651).** A pick, drop or paste with some unsupported files attaches the rest and says how many it skipped; the Compose and paste-dialog pickers now report an unsupported pick instead of doing nothing.
- **"Not UTF-8" for refused text uploads (#652).** A text file that fails the UTF-8 check is told it is not UTF-8 (or UTF-16, when it starts with that byte order mark) and to save it as UTF-8, instead of reading like a corrupt file.
- **Link taps on a phone after a tab switch (#654).** With the view parked above the bottom, a URL typed into the prompt no longer opens on tap, and a link in the scrollback opens on its own row.
**Fixes applied while landing.** The Copilot run mode clamps every `COPILOT_*` variable that bypasses approval, runs code or moves the token for non-granted multi-user owners, keeps a divider row inside an answer as part of what `codeman agent read` returns, and its Docker docs no longer promise a token you cannot set there (#592). Antigravity's working line only matches at the start of a row, so an answer that quotes the spinner no longer holds the tab busy (#648). The custom-folder refusal names the cases folder itself and a direct child separately (#567). The WS pill reads its queue once per update (#647). The "Pin tab names" setting is translated for zh-CN and listed in the Settings Reference (#646). JSON files the page fetches at runtime (the update note, the security notices) are now served `no-cache` instead of cached for a year, so a later notice always reaches a browser that already has the old copy.
**Docs and tests.** New route tests for nested custom-folder cases and for Copilot's screen-read answers, a wrapped-composer touch test, stricter upload parity checks, and docs that move Antigravity and Copilot into the screen-checked idle group.
## 1.42.0
### Minor Changes
- 1238316: ### Thanks
- @JDProfresh for five PRs: prompt uploads that take documents (#621, from the plan in #553), a video Codeman cannot take refused before it uploads (#622), a prompt-cache readout kept to the CLIs that report one and a statusline report with many cache-miss causes no longer thrown away (#623), and two fixes for Claude's workspace trust dialog in a brand-new folder: idle waits that hold until the dialog is answered (#634) and a dialog that gets answered every time (#635).
- @Randalix for Codex prompts that really get submitted, sent as one bracketed paste and a separate Enter (#628), and for the careful measurements in #627 that found the cause.
- @ludovicfourrage, a first-time contributor, for reporting (#629) and fixing (#632) the nested tmux status bar colour that leaked into live output after a history reload.
- @opticon454 for three PRs: a one-time "what's new" note after each update (#618), Previous and Next Session as rebindable shortcuts that refuse a chord another action already uses (#617), and a DeepSeek install from CLI management that ends with a DeepSeek you can run (#616).
**Git worktrees for a case (#625).** Give every agent on a repository its own checkout. Pick a case whose folder is a git repository, choose **Git worktrees…** in the case picker (**Worktrees** on the phone's case sheet), type a name and pick the CLI: Codeman creates `<case>/.claude/worktrees/<name>` on a new branch `worktree-<name>` and opens a session there, in a tab named after the worktree. **Open** starts another session in a worktree you already have. Removing one runs plain `git worktree remove`, never `--force`, so uncommitted work stays where it is, and Close Session can kill the session and remove its worktree in one go. The layout is the one Claude Code's own `claude --worktree` uses, so each sees the other's worktrees. To keep the main checkout clean, Codeman lists the worktrees folder and the files it writes for sessions in the repository's local `.git/info/exclude`; your `.gitignore` is never touched.
**Codex sessions turn red when Codex asks you something (#624).** Codex's command and edit approvals, its question dialogs and the trust prompt on a new repository are read straight off the screen and raised the way a Claude permission prompt is: red tab, desktop notification, push, webhook, the phone's NEEDS YOU row and an Approvals Inbox card that tells you to answer in the terminal. An agent driving a codex worker can now wait for that with `until=blocked`.
**Prompt uploads take documents (#621).** Attach, drop or paste a PDF, a Word, PowerPoint or Excel file, a zip, or a text file (txt, md, csv, tsv, json, log, yaml, xml) and its path lands on your prompt, ready to send, like a photo or a video does. Every file is checked against its actual bytes on the way in, so a program renamed to `.pdf` is refused, and text files must be UTF-8. The saved name keeps a readable part of the original: `Q3 report.pdf` is saved as `paste-...-Q3-report.pdf`. The button in the Compose and paste dialogs now says Attach.
**Previous and Next Session are rebindable (#617).** Previous Session (Alt/Option+[) and the alternate Next Session (Alt/Option+]) are now ordinary rows in App Settings > Shortcuts, so you can rebind or switch them off like everything else. Capturing a chord another action already uses is refused with a message naming that action (Alt+1 to 9, Ctrl/Shift+Enter and Ctrl/Cmd+V stay reserved), and re-enabling or resetting a shortcut runs the same check. A chord you rebound no longer also types into the terminal: a rebind to Alt+J used to switch tabs and send ESC j to the session too. Built-in defaults like Ctrl+L still reach the CLI.
**A note after each update (#618).** The first time a browser loads a newer Codeman, a toast lists that release's headline changes, with a **See what's new** button that opens the full release notes. It shows once per version, and a browser that has never opened Codeman before just records the version and stays quiet, so the first note you will see is the one for the release after this one. The headlines are taken from the changelog when the release is cut.
**Multi-user mode: only admins change server-wide settings.** In multi-user mode any signed-in regular user could rewrite the server's settings (feature switches, run mode, case order, the default model for cron and the orchestrator, the app config), start a self-update, rotate the tunnel QR code, clear stale session state, and sign every user out with Revoke All, admins included. They could also read the Deepgram API key an admin saved. Those writes and operations now answer 403 to a regular user, Revoke All signs them out of their own logins only, and their settings read leaves out the admin's key and case names. In the browser a regular user's per-device settings still save, a change to a server-wide setting shows one notice that only an admin can change it, instead of pretending it was saved, and their own run mode and respawn presets are kept on their device. Single-user mode is unchanged.
**Fixes.**
- **Codex submits long prompts (#628).** Prompts sent through the API, `codeman agent send`, the MCP server, cron or the agent skill go into the pane as one bracketed paste followed by a separate Enter, so Codex no longer leaves them sitting unsent in its composer. A single character (a menu answer like `3` or `y`) is still typed, so numbered dialogs keep picking the option you sent, and a trailing `;` survives, so `find . -exec rm {} \;` arrives whole.
- **New Claude sessions in an untrusted folder (#634, #635).** An agent waiting for a fresh session to go idle no longer gets its answer while the workspace trust dialog is still up, so its first prompt can no longer press Enter on the highlighted "No, exit" and kill the session it just started. And the dialog is now answered every time: a screen read that came too soon after the previous one used to be dropped, which now and then left a new session sitting on the dialog. Deleting a session while the dialog is up also stops Codeman reading its dead pane for seconds afterwards.
- **Nested tmux colours after a history reload (#632).** Opening a remote or Docker case, or a shell running tmux inside it, no longer hands the nested tmux status bar's green background to whatever the agent prints next.
- **The codeman skill with arguments (#642).** The skill works again when Claude Code loads it with arguments ("use codeman to start 5 claude workers"). Claude Code swaps a skill's `$1` and `$2` for words of your request, which broke the pasted helpers; they now use `${1}` and `${2}`, and a cached copy written by the broken version is rewritten on the next run.
- **DeepSeek installs ready to run (#616).** Installing the terminal profile finds the `pnpm` next to `dsh` even when the server's own PATH lacks `~/.local/bin`, and the DeepSeek install command adds `pnpm` when the machine has none.
- **Unsupported videos refused up front (#622).** An Android `.3gp` or an `.mkv` gets "Unsupported file type" right away, instead of uploading up to a gigabyte and failing at the end.
- **Statusline reports (#623).** The prompt-cache readout is stored only for CLIs whose statusline reports one (Claude today), like the model badge, and a Claude report with more than 20 cache-miss causes keeps the first 20 instead of being refused, which used to blank the footer and the plan-usage chip.
**Fixes applied while landing.** Removing a worktree that is, sits inside or holds a Docker case's workspace is refused before git runs; it would have deleted the folder a container still mounts (#625). Codex's trust prompt also reads as blocked in the variant reached through its "Update available" screen, which ends in "esc back" (#624). The DeepSeek install only adds `pnpm` when none is on PATH, so a machine with a corepack `pnpm` keeps it instead of the whole install failing (#616). Three statements in the upload docs were corrected: only a local agent and a Docker case share the uploads folder (remote sessions are refused), GIFs keep their frames instead of being re-encoded, and a developer note no longer names a release that never shipped (#621). The update note skips the Thanks list and group labels like "Fixes.", still works for a release with no headlines, and is translated for zh-CN (#618).
**Docs.** The README and its Chinese translation caught up with 1.41.0 and 1.41.1 and show a sharper hero GIF, the Codeman Skill got a wiki page and two GIFs linked from both READMEs, CLAUDE.md was split into the root file, `src/web/CLAUDE.md` and `docs/releasing.md`, the release steps now include the MCP registry publish, and a new note says where video and GIF masters live (not on GitHub).
## 1.41.1
### Patch Changes
+21 -19
View File
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Task | Command |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) |
| Type check | `npm run typecheck` (= `tsc --noEmit`) |
| Type check | `npm run typecheck` (= `tsc --noEmit` + `tsc -p config/tsconfig.scripts.json`) |
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
| Format | `npm run format` (check: `npm run format:check`) |
| Tests | `npm test` (the CI gate — safe to run bare) · one file: `npm test -- test/<file>.test.ts` · see Testing for the excluded suites |
@@ -44,7 +44,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
3. **Only after verification passes**, proceed with COM
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
The production server caches static files for 1 year, `immutable` (`setHeaders` in `server.ts`), except the `.json` data files under `public/` (`whats-new.json`, `security-notices.json`, `manifest.json`), which are served `no-cache` because they are neither content-hashed nor `?v=`-busted (pinned by `test/static-cache-headers.test.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
## COM Shorthand (Deployment)
@@ -52,13 +52,13 @@ 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.41.1 (must match `package.json`)
**Version**: 1.43.0 (must match `package.json`)
## Project Overview
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`) and OMP (`omp`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'`).
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`) and GitHub Copilot (`copilot`, npm `@github/copilot`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'`).
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
@@ -92,11 +92,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*` vs `OMP_*` vs `COPILOT_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`, `docs/omp-integration.md`, `docs/copilot-integration.md`
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
@@ -120,11 +120,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Config**: `src/config/` — flat files plus the `cli-registry/` subdir, no barrel (`index.ts`) exists; import from the specific file. ⚠️ There are TWO `config/` directories: the repo-root `config/` holds tooling only (ESLint, knip, the vitest configs, `test-suites.ts`), while runtime config lives in `src/config/`. Throughout this file a bare `config/<name>.ts` in a code context means `src/config/<name>.ts`.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver` (CLI path resolution, one per `SessionMode`, all nine sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver`/`copilot-cli-resolver` (CLI path resolution, one per `SessionMode`, all ten sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
### Key Patterns
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. 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)
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. 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`
@@ -138,7 +138,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The workspace-trust dialog's `❯` (on `No, exit`) arms the check too, so the same capture holds idle off while `isTrustDialogScreen()` matches inside `TRUST_DIALOG_WINDOW_MS`: an agent that waited for idle and prompted the dialog confirmed `No, exit` and killed the pane. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
@@ -150,7 +150,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (`compactScreenText()`, all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind `\r` (newer versions highlight "No, exit" first): `trustDialogNextKey()` returns ONE key per re-read frame (arrow, then Enter only once `❯` is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window `TRUST_DIALOG_WINDOW_MS` (90s), two-marker match (`isTrustDialogScreen`), attempt cap `TRUST_DIALOG_MAX_ATTEMPTS` (6). ⚠️ Read `capturePaneText()`; only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (`_trustDialogTimer`, cleared in `_clearAllTimers()`), not rely on PTY output. → [architecture-invariants#workspace-trust-dialog-auto-accept](docs/architecture-invariants.md#workspace-trust-dialog-auto-accept)
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (`compactScreenText()`, all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind `\r` (newer versions highlight "No, exit" first): `trustDialogNextKey()` returns ONE key per re-read frame (arrow, then Enter only once `❯` is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window `TRUST_DIALOG_WINDOW_MS` (90s), two-marker match (`isTrustDialogScreen`), attempt cap `TRUST_DIALOG_MAX_ATTEMPTS` (6). ⚠️ Read `capturePaneText()`; only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (`_trustDialogTimer`, cleared in `_clearAllTimers()`), not rely on PTY output, and a throttled read is DEFERRED onto that timer, never dropped (the dialog's paint is often the pane's last output); a stopped session never scans. → [architecture-invariants#workspace-trust-dialog-auto-accept](docs/architecture-invariants.md#workspace-trust-dialog-auto-accept)
**Process-tree walks are bounded** (`proc-tree.ts`, pure): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by one cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: async, in-flight-shared, and ANY error discards the result rather than caching a truncated `ps`). ⚠️ Never walk a process tree with per-node `pgrep` or unbounded recursion (the unbounded version took a machine down): the walk must terminate on cycles, cap depth (`PROC_WALK_MAX_DEPTH`) and node count (`PROC_WALK_MAX_NODES`), and never spawn anything. ⚠️ Keep it in its own module so the test exercises the shipped code, and report truncation through `onTruncated` naming both caps, never silently. → [architecture-invariants#process-tree-walks-are-bounded](docs/architecture-invariants.md#process-tree-walks-are-bounded)
@@ -172,7 +172,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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.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`, 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`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
**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)
@@ -182,7 +182,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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 and `POST /api/sessions/:id/input` 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`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; 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 and `POST /api/sessions/:id/input` 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.
@@ -202,11 +202,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph-and-pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**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 gets 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 to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `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. 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 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 to the CLI for **claude ≥ 2.1.187 and copilot ONLY, and only while they have mouse tracking on** (copilot has no version gate; with a stale flag it falls back to PageUp/PageDown through its `transcriptPageKeys` capability) (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `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. 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)
@@ -215,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
**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 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). ⚠️ The client classifies a pick through ONE helper, `_promptAttachKind()` (MIME type first, then the name's extension, because some Android file managers hand over a File with none); every pick and paste filter (Attach, drop, the tile grid's drop in `tile-grid.js`, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never `file.type` directly. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (`_tilesOwnTerminal()` in the `_attachFromToolbar` guard). Tests: `test/toolbar-attach.test.ts`, `test/mobile-prompt-composer.test.ts`, `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
**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 adds `~/`, relative and folder paths without overlapping a rooted match or a 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)
@@ -229,15 +229,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**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)
**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)
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
@@ -271,7 +273,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`) |
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
@@ -291,7 +293,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`.
- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. ⚠️ A synced setting is admin-only to write in multi-user mode (the PUT 403s a non-admin, whose browser does not send it): a new route that writes `settings.json` takes the same `isMultiUserMode() && !requireAdmin(req, reply)` gate.
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`), and a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`; mobile tests get theirs from `createTestServer()` and read `boundPort` too. `test/test-ports-guard.test.ts` refuses a fixed port in either. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`.
+23 -13
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Terminal - One Dashboard &bull; Any Device</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
@@ -27,7 +27,7 @@
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
Get started in one line (macOS & Linux, Windows via WSL):
@@ -42,7 +42,7 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
- **One dashboard, nine CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
@@ -70,7 +70,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **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.
- **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), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine 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:
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:
```bash
codeman web
@@ -259,7 +259,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, or `Terminal` (plain shell). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -274,10 +274,10 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 4. Talk to the agent
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
- **Paste, drop or attach images and videos** directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
- **Paste, drop or attach images, videos and documents** (PDF, Office files, zip and plain text) directly into the session. They are saved in a hidden `.codeman-uploads/` folder in the workspace (it ignores itself in git and is swept after 7 days), and the file's path lands on your prompt, unsent.
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, or this machine's Claude Code login with no API key; auto-silence stop).
- **Attachments** — register external files/docs and preview Office/PDF inline; any file path an agent prints is clickable, in the terminal and in the chat view, including `~/` and relative paths and paths broken across lines. An `.html` report opens as a real page, Markdown opens rendered, and a folder opens as a grid of its files.
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues every pending prompt across sessions, answerable from the header bell or the phone home screen, and 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
- **When it needs you** — the tab turns yellow (waiting for input) or red (a question is blocking). The **Approvals Inbox** _(opt-in)_ queues the prompts waiting on you from Claude Code, Codex and DeepSeek Harness sessions in one list. Claude prompts are answerable from the header bell, Claude dialogs also from the phone home screen, and a Claude permission prompt also from a push notification's Approve/Deny buttons; Codex and DeepSeek items are alerts that tell you to answer in the terminal, and prompts from the other CLIs do not enter it. 🧠 **Read My Mind** _(opt-in)_ drafts your next prompt from the case's goals and recent work.
- **Copy what you see** — `Shift+drag` selects text even while the CLI owns the mouse, right-click copies it, and Auto Copy _(opt-in)_ copies a selection the moment you release it. A program that copies with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard too.
### 5. Make it autonomous
@@ -463,7 +463,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. Read-only and offline (Codeman never fetches or changes the repo); 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.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** 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_*`). 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) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **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)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
@@ -684,7 +684,7 @@ These run for **every** request — before auth, even on the default no-password
### Input, files & headers
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` / `COPILOT_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes. The one place HTML renders is the File Viewer's page view: a separate `/html-view/<capability>/` address scoped to the file's own folder, served under `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page has an opaque origin and can never read Codeman's cookies or drive its API; hidden folders and broad roots (`/`, home, `/tmp`) are refused, and the capabilities are revoked on logout
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
@@ -753,7 +753,7 @@ For AI agents and automation that control Codeman without a browser: an agent th
### The agent skill (start here)
Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself.
Everything in this section also ships as an **agent skill**, [`skills/codeman`](skills/codeman/SKILL.md), in the `SKILL.md` format Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. Install it once and you never paste API docs into a prompt again. You ask for what you want in plain English, and the agent already sitting inside a Codeman session loads the recipes and drives the API itself. Full guide, with every install route, the recordings and what it will and will not do on its own: [The Codeman Skill](https://github.com/Ark0N/Codeman/wiki/Codeman-Skill).
<p align="center">
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both" width="900"></a>
@@ -783,9 +783,19 @@ That is the entire interface. No curl, no endpoint names, no session ids. These
| _"Have a claude worker on `refactor-auth` summarize `src/session.ts`, then close it."_ | Spawns, runs the readiness ladder (first-run trust dialog included), send-and-wait, reads the clean transcript answer, deletes. |
| _"Watch session w4 and tell me if it gets stuck on a permission prompt."_ | Blocks on the `blocked` signal and surfaces the question to **you**. It never answers another session's prompt itself. |
One short line is enough. Here a Claude Code lead gets `use codeman to start 5 claude workers, one per TODO item`, and five real Claude Code workers power on, shown side by side in the tile grid:
<p align="center">
<img src="docs/images/codeman-skill-grid-20261015.gif" alt="A real codeman skill run in the tile grid: the lead Claude Code session powers on as the only tile, the prompt use codeman to start 5 claude workers, one per TODO item is typed, then five Claude Code worker sessions power on as new tiles one after another with the CRT animation, and all six agents work at once" width="900">
</p>
#### Step 3: nothing
The agent deletes every session it started. Watch the tabs appear and disappear in the dashboard while it works.
The agent deletes every session it started. Watch the tabs appear and disappear in the dashboard while it works. With eight workers, each one gets its own tab, and a lineage line runs back to the lead that spawned it:
<p align="center">
<img src="docs/images/codeman-skill-tabs-20261015.gif" alt="A real codeman skill run in the normal view: the prompt use codeman to start 8 claude workers, one per TODO item is typed, eight worker tabs power on left to right while lineage lines draw from the lead to each one, then clicking a worker tab shows a live Claude Code worker receiving its task" width="900">
</p>
#### A real run, start to finish
@@ -925,7 +935,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp) have no transcript:
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot) have no transcript:
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
# BYTES, and what comes back is terminal data, ANSI included.
@@ -1145,7 +1155,7 @@ flowchart TB
end
subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"]
end
end
+23 -13
View File
@@ -5,7 +5,7 @@
<h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Copilot &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
@@ -29,7 +29,7 @@
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness 或 OMP,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 GitHub Copilot CLI,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
@@ -44,7 +44,7 @@ codeman web
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
- **一个仪表盘,九个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek 或 OMP](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
@@ -72,7 +72,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
- **给急性子的参数。** `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)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
你至少需要安装一个 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),也可以选择跳过、稍后自行安装。安装完成后:
```bash
codeman web
@@ -261,7 +261,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
| 字段 | 作用 |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP` 或 `Terminal`(普通 shell)。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot` 或 `Terminal`(普通 shell)。 |
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
@@ -276,10 +276,10 @@ codeman service install # systemd/launchd 服务:重启后自动回
### 4. 与智能体对话
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
- **粘贴、拖放或附加图片和视频**,直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
- **粘贴、拖放或附加图片、视频和文档**(PDF、Office 文件、zip 和纯文本),直接进入会话。它们保存在工作区里一个隐藏的 `.codeman-uploads/` 文件夹中(它在 git 里会忽略自己,7 天后被清理),文件路径会落在你的提示里,但不会自动发送。
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,或者直接用这台机器的 Claude Code 登录、不需要任何 API key;自动静音停止)。
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF;智能体打印出的任何文件路径都可以点击,终端里和对话视图里都行,包括 `~/` 路径、相对路径和被折成多行的路径。`.html` 报告会以真实网页打开,Markdown 会渲染后打开,文件夹则以其文件的网格打开。
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把所有会话里等着你的提示排成一个队列,可以从页头的铃铛或手机首页直接作答;🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
- **需要你的时候** —— 标签会变黄(等待输入)或变红(有个问题挡住了它)。**审批收件箱(Approvals Inbox)**(可选启用)把 Claude Code、Codex 和 DeepSeek Harness 会话里等着你的提示排成一个队列。Claude 的提示可以在页头的铃铛里直接作答,Claude 的对话框也可以在手机首页作答,Claude 的权限请求还可以用推送通知上的 Approve/Deny 按钮作答;Codex 和 DeepSeek 的条目只是提醒,会告诉你去终端里作答;其他 CLI 的提示不会进入收件箱。🧠 **Read My Mind**(可选启用)会根据这个 case 的目标和最近的工作替你起草下一条提示。
- **看到什么就能复制什么** —— `Shift+拖动` 在 CLI 接管了鼠标时也能选中文本,右键复制选中内容,自动复制(Auto Copy,可选启用)在松开鼠标的瞬间就复制。用 OSC 52 复制的程序(Claude Code 的 `/copy`、tmux 的 yank、vim)也会把文本放进你浏览器的剪贴板。
### 5. 让它自主运行
@@ -465,7 +465,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 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`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)
- **多 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)
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
@@ -686,7 +686,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
### 输入、文件与响应头
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` / `COPILOT_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行。HTML 唯一会被渲染的地方是文件查看器的网页视图:一个独立的 `/html-view/<capability>/` 地址,只覆盖该文件所在的文件夹,响应带 `Content-Security-Policy: sandbox` 且不含 `allow-same-origin`,因此页面是不透明源,永远读不到 Codeman 的 cookie,也驱动不了它的 API;隐藏文件夹和范围过大的根目录(`/`、主目录、`/tmp`)会被拒绝,退出登录时这些凭证也会被吊销
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
@@ -755,7 +755,7 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
### 智能体技能(从这里开始)
这一节的所有内容也打包成了一个**智能体技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md),采用 Claude Code、Codex、Gemini CLI、OpenCode、Grok、OMP 以及大多数其他智能体 CLI 都能读取的 `SKILL.md` 格式。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。
这一节的所有内容也打包成了一个**智能体技能**,位于 [`skills/codeman`](skills/codeman/SKILL.md),采用 Claude Code、Codex、Gemini CLI、OpenCode、Grok、OMP 以及大多数其他智能体 CLI 都能读取的 `SKILL.md` 格式。装一次,就再也不用把 API 文档粘进提示词。你用大白话说想要什么,已经坐在 Codeman 会话里的智能体会自己加载配方并驱动 API。完整说明(所有安装方式、录屏,以及它会自己做什么、不会做什么):[The Codeman Skill](https://github.com/Ark0N/Codeman/wiki/Codeman-Skill)(英文)。
<p align="center">
<a href="docs/images/codeman-skill-crt-20261010.png"><img src="docs/images/codeman-skill-crt-20261010.gif" alt="一次真实的 codeman 技能运行:在 Claude Code 里输入一句简短的提示,平铺网格开机,然后一个跑在本地 qwen 模型上的 DeepSeek Harness 工作会话和一个 Claude Code 工作会话作为新窗格开机,主会话到两者之间画出派生连线" width="900"></a>
@@ -785,9 +785,19 @@ Web UI 仍是主要界面;完整指南见 **[docs/tui.md](docs/tui.md)**(英
| _「让一个 claude 工作会话在 `refactor-auth` 上总结 `src/session.ts`,然后关掉它。」_ | 拉起、走完就绪阶梯(包括首次运行的信任对话框)、发送并等待、读取干净的 transcript 答案、删除。 |
| _「盯着会话 w4,如果它卡在权限提示上就告诉我。」_ | 阻塞在 `blocked` 信号上,并把问题交给**你**。它绝不会替另一个会话回答提示。 |
一句简短的话就够了。下面这个 Claude Code 主会话收到 `use codeman to start 5 claude workers, one per TODO item`,五个真实的 Claude Code 工作会话依次开机,并排显示在平铺网格里:
<p align="center">
<img src="docs/images/codeman-skill-grid-20261015.gif" alt="一次真实的 codeman 技能运行(平铺网格):主 Claude Code 会话作为唯一的窗格开机,输入提示 use codeman to start 5 claude workers, one per TODO item,然后五个 Claude Code 工作会话以 CRT 动画依次作为新窗格开机,六个智能体同时工作" width="900">
</p>
#### 第 3 步:没有了
智能体会删掉它启动的每一个会话。你可以在仪表盘里看着标签出现又消失。
智能体会删掉它启动的每一个会话。你可以在仪表盘里看着标签出现又消失。起八个工作会话时,每个都有自己的标签,还有一条派生连线连回启动它的主会话:
<p align="center">
<img src="docs/images/codeman-skill-tabs-20261015.gif" alt="一次真实的 codeman 技能运行(普通视图):输入提示 use codeman to start 8 claude workers, one per TODO item,八个工作会话标签从左到右依次开机,主会话到每个标签之间画出派生连线,然后点开一个工作会话标签,看到一个正在接收任务的真实 Claude Code 工作会话" width="900">
</p>
#### 一次真实的运行,从头到尾
@@ -923,7 +933,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp)没有 transcript,读终端。
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot)没有 transcript,读终端。
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
@@ -1144,7 +1154,7 @@ flowchart TB
end
subgraph External["外部"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
BG["后台智能体<br/><small>(Task 工具)</small>"]
end
end
+30 -2
View File
@@ -238,8 +238,8 @@
},
"install": {
"command": {
"linux": "npm install -g @deepseek-ai/dsh",
"darwin": "npm install -g @deepseek-ai/dsh"
"linux": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)",
"darwin": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)"
},
"npmPackage": "@deepseek-ai/dsh",
"docsUrl": "https://github.com/deepseek-ai/deepseek-harness",
@@ -277,5 +277,33 @@
"docsUrl": "https://omp.sh"
}
}
},
{
"id": "copilot",
"label": "GitHub Copilot",
"shortBadge": "CP",
"enabled": true,
"order": 100,
"kind": "agent",
"discovery": {
"binaries": [
"copilot"
],
"searchDirs": [
"~/.local/bin",
"/usr/local/bin",
"~/.bun/bin",
"~/.npm-global/bin",
"~/bin"
],
"install": {
"command": {
"linux": "npm install -g @github/copilot",
"darwin": "npm install -g @github/copilot"
},
"npmPackage": "@github/copilot",
"docsUrl": "https://github.com/github/copilot-cli"
}
}
}
]
+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.41.1",
"version": "1.43.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.41.1",
"version": "1.43.0",
"transport": {
"type": "stdio"
},
+1
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/tmux-capture-sgr.browser.test.ts',
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/tab-layout-editing.browser.test.ts',
+1 -1
View File
@@ -126,7 +126,7 @@ RUN set -eux; \
# ⚠️ The default is in REGISTRY order, byte-identical to what the generator emits.
# A different order is a different RUN string, which is a different layer hash and
# so a needless cache miss between a bare `docker build` and a scripted one.
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli @github/copilot"
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
+5 -2
View File
@@ -761,9 +761,12 @@ workers. Tests: `test/name-flag-injection.test.ts`.
Later narrowing: `--name` is not only the peer name but also the `/resume` picker
entry and the terminal title, and a pinned title stops Claude generating its own, so
pinning the `w1-myapp` placeholder listed every conversation of a case under the same
name in `/resume`. Only a manual name is pinned now (`Session.cliPinnedName`,
name in `/resume`. Only a manual name is pinned by default (`Session.cliPinnedName`,
`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto
names leave Claude to title the conversation. A rename in Codeman appends a
names leave Claude to title the conversation. The opt-in synced `pinCliSessionName`
(default OFF, re-read at every spawn and respawn) pins every tab name instead, at the
cost of Claude's generated titles; a rename then holds only until the next turn, because
a pane spawned with `--name` re-appends its in-memory title. A rename in Codeman appends a
`custom-title` row to the conversation's transcript (`claude-session-title.ts`), the
row `/rename` writes. Tests: `test/claude-resume-title.test.ts`,
`test/routes/session-name-routes.test.ts`.
+102 -14
View File
@@ -78,10 +78,15 @@ the HTTP status.
|-------------|------|---------|
| `INVALID_INPUT` | 400 | Malformed request / failed validation |
| `UNAUTHORIZED` | 401 | Authentication required or failed |
| `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) |
| `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else |
| `NOT_FOUND` | 404 | Resource does not exist |
| `USER_NOT_FOUND` | 404 | Multi-user: no user with that name |
| `SESSION_BUSY` | 409 | Session is busy |
| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) |
| `ALREADY_EXISTS` | 409 | Resource already exists |
| `USER_EXISTS` | 409 | Multi-user: a user with that name already exists |
| `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin |
| `OPERATION_FAILED` | 422 | Well-formed but could not be completed |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
@@ -211,8 +216,10 @@ runs no agent, and the external CLIs render their own TUIs and post no hooks
(`deepseek`'s status bridge reports the same two, see its own docs). The one exception
is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off
its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode
only `idle`, `working` and `exit` are accepted, and of those only `exit` is
dependable: see the caveats under [Signals](#signals) before building on `idle`.
only `idle`, `working` and `exit` are accepted. `exit` is dependable everywhere; `idle` and
`working` are screen-checked for `opencode`, `gemini`, `pi`, `omp` and `antigravity` (as for `claude` and
`codex`) and not guaranteed elsewhere: see the caveats under [Signals](#signals) before
building on `idle`.
Requesting a signal the session can never emit **explicitly** is a `400` (`stop` on
`codex` included); omitting `until` never fails, the server just drops them from the
default set and echoes the narrowed set back as `wait.until`. Three more places hooks can go
@@ -239,8 +246,8 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
| Signal | Source | Actually fires for |
|--------|--------|--------------------|
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `shell`: **once only**, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude` only in practice (spinner and work-keyword detection are Claude output formats) |
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph |
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only |
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below |
| `exit` | no process is behind the session | every mode |
@@ -252,9 +259,9 @@ the wait promptly instead of burning the caller's whole timeout on something tha
can no longer happen). On a `claude` worker, prefer an explicit `until=stop,exit`
once the session is up: the default set's `idle` also resolves on a spinner pause,
and on a fresh session the **startup** `idle` (emitted when the CLI first comes up)
can land inside your first wait window and report a turn that never ran. Measured:
a session parked on the trust dialog emits no *further* `idle`, so it is the
startup transition, not the dialog, that produces the false success below.
can land inside your first wait window and report a turn that never ran. That
startup `idle` is held while Claude's workspace-trust dialog is on screen (inside
the 90 s startup window), because the dialog's own ❯ is not a composer.
⚠️ **`exit` means "nothing is running", which includes "not started yet".** The
server answers from `pid === null` plus a mux-layer pane-death probe, and that
@@ -303,11 +310,11 @@ instead. The same caution applies to the external CLIs.
Nothing here reports "the agent is ready for a prompt", and no combination of
`until`/`fresh` synthesizes one. A freshly created session reads as `exit` (above),
and a `claude` worker in a brand-new case comes up on the CLI's **trust dialog**,
which contains a ❯ prompt of its own. Send-and-wait posted at that moment types the
prompt into the dialog, where the `\r` never gets past it, while the session's
startup `idle` lands inside the wait window: the wait resolves on `idle` in a
couple of seconds with `timedOut: false`, which looks exactly like a completed
turn.
which contains a ❯ of its own. A prompt posted at that moment is typed into the
dialog, and its `\r` confirms the highlighted `No, exit`: the CLI quits and the
pane dies, so send-and-wait resolves on `exit`. Codeman holds the session's `idle`
while the dialog is on screen, so waiting for `idle` before prompting no longer
clears too early, but that still does not make `idle` a readiness signal.
The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is
non-null, then `wait-output` for the composer's own marker (`bypass`, the status
@@ -613,6 +620,23 @@ beside the 30 per minute the image path keeps; without the flag the route is
byte-identical to the image-only version, same 50 MB image cap and same 30/min
bucket. The response shape is unchanged (`path`, `filename`).
Beyond images, the route also accepts documents a user hands an agent to read,
admitted by the name's extension (the Content-Type a phone attaches to a `.md`
or `.csv` is unreliable): the office formats `.pdf`, `.docx`, `.pptx`, `.xlsx`,
`.zip` and the text family `.txt`, `.md`, `.csv`, `.tsv`, `.json`, `.log`,
`.yaml`, `.yml`, `.xml`. A document is buffered under the same 50 MB image cap,
its bytes are checked against the extension (a magic-number signature, or UTF-8
with no NUL byte for text) and answered 415 on a mismatch (for text, an `error`
saying the file is not UTF-8 text, or UTF-16 text when it opens with a UTF-16
byte order mark, and to save it as UTF-8), and it is saved under
a sanitized stem of its name, so `Q3 report.pdf` is findable as
`paste-<ms>-<hex>-Q3-report.pdf`. A named type that is neither an image nor a
document (a script, an HTML page, a `.sh`) is refused 400 `Unsupported file
type`. That refusal is a scope choice, not a security property: `file-raw`
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.
## Rendered HTML files
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
@@ -996,7 +1020,7 @@ The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`.
- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`.
- It must not be the cases directory (the caller's own or the shared one) or a direct child of it, since those are what `GET /api/cases` lists as local cases: a case there is a plain create without `path`. A folder further down is allowed. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it.
- 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.
@@ -1028,6 +1052,70 @@ Both routes accept two optional query parameters, which the UI sends from its pe
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.
## Git worktrees of a case
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
`GET /api/cases/:name/worktrees` lists `git worktree list` for the case's repository:
```json
{
"caseName": "myrepo",
"repoRoot": "/home/me/codeman-cases/myrepo",
"worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees",
"truncated": false,
"worktrees": [
{ "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false,
"locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] },
{ "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login",
"main": false, "isCase": false, "managed": true, "name": "fix-login",
"sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] }
]
}
```
`managed` is `true` only for `<repoRoot>/.claude/worktrees/<name>`: those are the ones Codeman starts sessions in and removes; any other worktree of the repository is listed for information. `sessionCount` counts live sessions of every owner running in that worktree (a session belongs to the deepest worktree holding its folder), `sessions` lists the ones the caller may see. `lockReason`/`prunableReason` appear when git reports them.
`POST /api/cases/:name/worktrees` with `{ name, branch?, base? }` creates one (`201`, `{ worktree: { name, path, branch, base, head, excludeAdded, warnings } }`):
- `name`: 1 to 64 characters of `A-Z a-z 0-9 . _ -`, starting with a letter or digit, no `..`, not ending in `.` or `.lock`. `400` otherwise.
- `branch`: the NEW branch, default `worktree-<name>`. Letters, digits, `.`, `_`, `-`, `/`, then `git check-ref-format --branch` must accept it unchanged. An existing branch is `409 ALREADY_EXISTS` (never reset), and so is a name whose folder already exists.
- `base`: where the branch starts, default `HEAD` (the case's current commit): a branch, tag or commit, resolved before anything is written (`400` when it is not a commit, which includes an empty repository).
- `excludeAdded` lists the lines appended to the repository's local `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git did not already ignore them: `/.claude/worktrees/`, so the main checkout does not show the worktrees as untracked, `/.claude/settings.local.json`, the hooks file Codeman writes for every claude session, which would otherwise make `git worktree remove` refuse, and the agent skill copy (`/.claude/skills/codeman/`, `/.agents/skills/codeman/`: every skills folder the CLI registry names) that sessions get while `agentSkillEnabled` is on, for the same reason.
- A symlinked `.claude` or `.claude/worktrees` directory is refused (`400`): a repository cannot redirect the worktree outside itself.
`DELETE /api/cases/:name/worktrees/:worktree[?deleteBranch=true]` removes a managed worktree with `git worktree remove` **without `--force`**: git refuses a worktree with modified or untracked files (or a locked one), and that refusal is the answer, `409 CONFLICT` with git's own words in `error`. A live session at or inside the worktree is `409` as well. With `deleteBranch=true` the branch is deleted with `git branch -d`, never `-D`, so an unmerged branch is kept: the response is still `200`, with `branchDeleted: false` and git's reason in `branchError`. Response: `{ removed: { name, path, branch, branchDeleted, branchError? } }`. A folder git does not list as a worktree of this repository, or a symlink in its place, is `404`/`400`. A worktree that is, sits inside, or holds a Docker case workspace is `400` too (git would run inside a folder the container writes, and the removal would delete that workspace under it). A managed worktree whose folder is gone (deleted by hand; the list reports it `prunable`) is still removable: git then only drops its record, which is what frees the name for a new worktree. A session never starts in one.
**Starting a session in a worktree** goes through the normal create paths: `POST /api/quick-start` takes `worktree: "<name>"` next to `caseName` and runs the session (and the hooks, the agent skill and `modelOverride`) in the worktree folder; the response adds `worktree` and `workingDir`. It is refused for remote and Docker cases (`400`), for a worktree that is not a registered, managed one (`404`), and for a case that does not exist (`404`: a worktree launch never scaffolds a case). `POST /api/sessions` takes the worktree path as `workingDir` like any other folder.
## Settings
`~/.codeman/settings.json` is one file for the whole server: the synced App Settings (feature switches, the plan-usage collection switch `showPlanUsageLimits`, `runMode`, `respawnPresets`, `tunnelEnabled`, ...), plus `modelConfig`, `caseOrder` and `lastUsedCase`. Reads are open to every signed-in user; in multi-user mode a non-admin reads it without what is the admin's (`voiceSettings.apiKey`, the Deepgram key an admin saved, and the admin's case names in `caseOrder` and `lastUsedCase`). **In multi-user mode every write is admin only:** for a non-admin the routes below answer `403 FORBIDDEN` before the body is even validated, so nothing is written, no watcher is toggled and no tunnel is started. Single-user mode is unaffected.
| Method | Path | Body | Notes |
| ------ | ----------------------------- | ------------------------------------ | ----- |
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). |
| `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). |
| `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
| `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. |
| `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
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).
### System operations
The same multi-user rule covers the operations that act on the whole server. For a non-admin:
| Method | Path | Multi-user non-admin |
| ------ | ----------------------------- | -------------------- |
| `POST` | `/api/system/update` | `403 FORBIDDEN` (the update restarts the server under every user) |
| `POST` | `/api/system/span-displays` | `403 FORBIDDEN` (opens a window on the server's desktop) |
| `POST` | `/api/tunnel/qr/regenerate` | `403 FORBIDDEN` (it clears every user's outstanding QR login code) |
| `POST` | `/api/cleanup-state` | `403 FORBIDDEN` (drops every user's stale session records) |
| `POST` | `/api/auth/revoke` | Revokes only the caller's own login sessions: the bare form (`{}`) signs them out everywhere, and a `sessionToken` is revoked only when it is theirs. For an admin, and in single-user mode, the bare form still revokes every login session |
| `GET` | `/api/tunnel/info` | `authSessions` lists only the caller's own logins |
## CLI management
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
@@ -1053,7 +1141,7 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `targets[]` — one per enabled CLI that declares an MCP config, including GitHub Copilot CLI (`id: "copilot"`; it is a run mode that declares its own `mcpConfig`, and a sync-only target takes its place only while that run mode is disabled): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
+1 -1
View File
@@ -11,7 +11,7 @@ One cross-session inbox for every prompt that is waiting on a human: permission
## Scope
- Claude mode only (hooks fire only for `claude`; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitive `stop`/`blocked` gating.
- Claude mode only (hooks fire only for `claude`; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitive `stop`/`blocked` gating. Since then, DeepSeek Harness (through its status bridge) and Codex (through dialogs read off its screen) open alert-only items; answerable items are still Claude's alone (see `src/web/approval-inbox.ts`).
- Permission prompts occur for sessions running `ClaudeMode` `normal` / `auto` / `allowedTools` (and the trust-folder dialog even under skip-permissions). Question and idle prompts occur in every mode including `dangerously-skip-permissions`.
- In-memory store (plus the frontend seeding from it on load). Server restart drops items; hooks re-fire on the next prompt. No new state file in v1.
+24 -8
View File
@@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
### Input delivery and WS resilience
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
@@ -203,7 +203,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it.
**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so by default only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry, because `_nextCaseSessionStartNumber()` numbers from the highest OPEN tab and restarts once a case's tabs are closed. The opt-in synced `pinCliSessionName` (default OFF) pins every non-blank tab name for users who want the CLI labels to match their tabs; it is read fresh at every claude create and respawn (`readPinCliSessionNameEnabled()`, hooks-config.ts, like `readPlanUsageTelemetryEnabled()`), so a toggle applies from the next spawn with no restart. ⚠️ Its cost: Claude stops generating its own titles for those sessions, and a rename holds only until the next turn, because a pane spawned with `--name` re-appends its in-memory title after every turn (`claude-session-title.ts`); a server restart re-attaches the live pane without respawning it. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it.
Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`) is the one `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip, and that `_nextCaseSessionStartNumber()` still counts. The `name` setter is reached via `PUT /api/sessions/:id/name`. The listener lives in `session-listener-wiring.ts`. Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.test.ts`.
@@ -221,11 +221,11 @@ 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) 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`. `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 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`.
⚠️ **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.
**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
**Only claude ≥ 2.1.187 and copilot, each with mouse tracking on, forward the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. **Copilot is the second forwarder** (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. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
**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 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). 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). `_wheelScrollLines()` 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`.
@@ -270,7 +270,9 @@ Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
@@ -312,7 +314,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ It reads `capturePaneText()` and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago.
⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing).
⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). ⚠️ For the same reason a read the `TRUST_DIALOG_RETRY_MS` throttle refuses is DEFERRED onto that timer, never dropped: the dialog's own paint is often the last output a launching pane produces and lands inside the throttle of the read the blank screen before it triggered, so dropping it left 2 of 12 fresh launches on Claude Code 2.1.294 sitting on the dialog with no keystroke ever sent. And a stopped session never scans: killing the pane produces output, the buffer tail still holds the dialog, and a deleted session went on scanning it and arming the timer for several seconds.
### Owner tab layouts
@@ -460,6 +462,20 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
### Git worktrees of a case
**Git worktrees** (case picker → **Git worktrees…**, the phone case sheet's **Worktrees**, Close Session's **Kill & remove worktree**): a LOCAL case whose folder is the top of a git repository keeps worktrees at `<repo>/.claude/worktrees/<name>` on a new branch (default `worktree-<name>`), so parallel agents each get their own checkout instead of sharing the case folder. That is Claude Code's own `--worktree` layout (measured in 2.1.296: same path, branch `worktree-<name>`, names of `[A-Za-z0-9._-]`, at most 64), and Codeman's names are a strict subset of Claude's. Core in `src/git-worktrees.ts` (pure name/branch gates, `worktree list --porcelain` parsing, exclude text; IO behind an injectable runner); routes in `routes/worktree-routes.ts` (`GET`/`POST /api/cases/:name/worktrees`, `DELETE /api/cases/:name/worktrees/:worktree`).
- ⚠️ **Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed.** Every component is `lstat`ed (a symlinked `.claude`, `worktrees` or worktree folder is refused, never followed) and the real path must equal the lexical one, so a repository cannot redirect a create or remove outside itself; removal also requires git to list the folder as a worktree of THIS repository. A worktree anywhere else is listed read-only (`managed: false`). A managed worktree whose folder is GONE stays removable (`resolveManagedWorktree({ allowMissing })`, removal only, never a session start: git lists it at exactly the managed path, no component on the way is a symlink, and `git worktree remove` then only drops git's record), since otherwise its name could never be used again.
- ⚠️ **git never runs for a case at or inside a Docker case workspace** (or whose common git dir is there), and Docker and remote cases are refused with a 400 before any git, the same rule as the git status indicator: `git worktree add` runs the repository's post-checkout hook and checkout filters ON THE HOST. That is acceptable for a local case, where the user's agent already runs code. A managed worktree that is, sits inside, or holds a Docker case workspace (a Docker case linked to the worktree folder) is never removed (400): `git worktree remove` runs a status inside the tree and then deletes it, ignored files included, under the container.
- ⚠️ **Removal is `git worktree remove` WITHOUT `--force`**, refused (409) while any live session (any owner) runs at or inside the worktree, and git's own refusal (modified or untracked files, a lock) is the answer, shown through `textContent`. The optional branch delete is `git branch -d`, never `-D`: an unmerged branch survives and the response says why (`branchError`).
- ⚠️ **The main checkout must not see the worktrees.** When `git check-ignore` says they are not ignored, `/.claude/worktrees/` is appended to the repository's LOCAL `<common git dir>/info/exclude` (never a tracked `.gitignore`), and so is `/.claude/settings.local.json`: Codeman writes its hooks there for every claude session, and an untracked copy in a worktree would make `git worktree remove` refuse forever (Claude Code covers that file with the global excludes file, which a machine may not have). The agent skill copy (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) gets a line too, for every skills folder the registry names (`capabilities.agentSkillDir`, passed in by the route as data). A symlinked `info` dir or `exclude` file is reported, never written through.
- **Names are gated before git**: charset and length (Zod and `worktreeNameError()`), then `git check-ref-format --branch`, whose output must EQUAL the input (`--branch` expands `@{-1}`, which the charset gate already refuses). The base is a resolved commit (`rev-parse --verify <base>^{commit}`), passed after `--` with `--no-track`; an existing branch is refused (`-b`, never `-B`, which would reset it). Create and remove are serialized per repository.
- **Sessions start through the EXISTING create paths**, never a new spawn path: `POST /api/quick-start` with `worktree` (resolved with the same guards; hooks, the agent skill, the model override and the session all target the worktree, and a case that does not exist is a 404, never a scaffold), or `POST /api/sessions` with the worktree as `workingDir` (what the Run button uses for claude and shell). The frontend launches through `run({ worktree })` with a temporary `_runMode` swap, tabs named `w<n>-<worktree>`.
- The unified session list names a live session's worktree from its folder layout (`worktreeFromPath`), so a codex or shell session gets the worktree pill before (or without) a transcript record.
Tests: `test/git-worktrees.test.ts` (pure half, then REAL git: create, exclude, symlink guards, dirty/live/locked refusals, `branch -d`), `test/routes/worktree-routes.test.ts` (real repository through the routes and quick-start, Docker/remote/ownership refusals), `test/worktree-ui.test.ts`, `test/worktree-modal-i18n.test.ts`.
### Ultracode and workflow-run visualization
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
@@ -695,7 +711,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Command palette and shortcut registry
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ Every xterm key handler (Pane A in terminal-ui.js, Pane B in terminal-tile.js) must call `isUserBoundShortcutEvent(ev)`, which is true only for a chord the user REBOUND a generically dispatched action to: the document capture handler's `preventDefault()` does not stop xterm, so a rebind to e.g. Alt+J would otherwise also type `ESC j` into the PTY. Defaults are deliberately not gated (Ctrl+L must reach the CLI for its redraw), so the gate also skips an entry re-captured onto its own default, `toggleSessionSidebar` (it has its own gate, `shouldToggleSessionSidebarFromShortcut`) and copy-selection. Capturing, re-enabling and Reset all go through `findShortcutConflict`/`findShortcutBindingConflict` (settings-ui.js); Alt+1-9, Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved there.
**A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events.
@@ -1108,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### Buffers, uploads, and terminal history
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 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. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.
+14 -7
View File
@@ -1,6 +1,6 @@
# The CLI registry
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP and GitHub Copilot CLI — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
## Where it lives
@@ -142,9 +142,10 @@ with newlines and tested once (`isBlockedScreen()`, `session-activity.ts`), so i
several markers and anchor the dialog's footer on the END of the window (no `m` flag: `$` is
the last row). Codex's needs a `› N.` option row and then its key-hint row (`Press enter to
confirm or esc to cancel`, `tab to add notes | enter to submit answer | esc to interrupt`,
`enter continue · esc quit` for the startup trust prompt), allowing for that row to wrap
onto rows of its own below about 60 columns. Measured on codex-cli 0.162.0 at widths 200 down
to 36: the command, edit and network-escalation approvals, single and multi-question
`enter continue · esc quit` for the startup trust prompt, `esc back` when it follows the
update prompt), allowing for that row to wrap onto rows of its own below about 60 columns.
Measured on codex-cli 0.162.0 at widths 200 down to 36: the command, edit and
network-escalation approvals, single and multi-question
`request_user_input` dialogs (notes field open or closed), and the workspace trust prompt
match; the idle and working views, popups the user opened (`/model`, `/permissions`,
`/review`), the composer holding the dialogs' words and a transcript quoting them verbatim
@@ -333,11 +334,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (and GitHub Copilot CLI, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
**Tools that are not run modes, or that declare no `mcpConfig`.** `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) copilot `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
@@ -346,3 +347,9 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
- `docs/architecture-invariants.md` — the mechanics and the history behind the rules above.
- `docs/deepseek-integration.md` — why DeepSeek is shaped the way it is.
## GitHub Copilot CLI
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist.
Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`, and so are the other `COPILOT_*` variables (read from the 1.0.98 bundle, far more than `copilot help environment` lists) that switch approvals, run code with no approval row or send the sign-in token elsewhere. The `COPILOT_` prefix admits whatever a later release adds, so the list needs a look whenever the measured version moves.
+158
View File
@@ -0,0 +1,158 @@
# GitHub Copilot CLI sessions
Codeman can drive [GitHub Copilot CLI](https://github.com/github/copilot-cli) (`copilot`,
npm `@github/copilot`) as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
Antigravity, Pi, Grok, DeepSeek Harness and OMP. `copilot` is a run mode: its own PTY, its
own tmux session, its own tab identity (purple, a drawn mark rather than GitHub's logo). It is
not a location overlay like Docker or remote-SSH cases, and it is not a web tab.
Everything here was measured against Copilot CLI 1.0.94 and 1.0.95 inside tmux. The entry
itself is registry data (`src/config/cli-registry/stock.ts`); see
[`cli-registry.md`](./cli-registry.md#github-copilot-cli) for the field-by-field reference.
## Install
```bash
npm install -g @github/copilot
```
CLI management (Settings → Agents & CLIs) can run exactly that for you. Codeman resolves the
binary from the server's PATH and the usual npm/nvm prefixes, then runs `copilot --version`
and accepts it only if the output says `GitHub Copilot CLI <x.y.z>`. A bare `copilot` on PATH
that answers anything else is ignored. Check what it resolved:
```bash
curl -s localhost:3000/api/copilot/status | jq
# { "available": true, "path": "/home/you/.local/bin", "version": "1.0.95" }
```
## Authenticate
Codeman never logs Copilot in or stores its credentials.
- **Interactive.** Run `copilot` once and use `/login` (or `copilot login`), or do it inside a
Codeman tab. The token goes to the OS keychain when one is available.
- **Headless server.** There is no keychain on a service, so Copilot offers to keep the token
as **plain text in `~/.copilot/config.json`** (`authTokens`). That is where it ends up when you
accept. To keep a token out of a file, set `COPILOT_GITHUB_TOKEN` in the service environment;
Copilot reads it first, then `GH_TOKEN`, then `GITHUB_TOKEN`.
- **Per-session override.** `COPILOT_*` is the only prefix the env allowlist admits for
Copilot. `GH_TOKEN` and `GITHUB_TOKEN` are deliberately **not** accepted as per-session
overrides: the allowlist is one global list with no mode context, so admitting them would
make them settable on every session. Use `COPILOT_GITHUB_TOKEN`.
- **No GitHub sign-in is needed** when a session points at your own endpoint (below).
## What Codeman wires up
| Thing | Behaviour |
| --- | --- |
| Launch | `copilot`, in Copilot's own Manual Approval mode. |
| Run button | `copilot --yolo` (allow every tool, path and URL), like the other CLIs' bypass switches, so an unattended session never stops on an approval. |
| Permission clamp | `allowAll` is a `privilegedParam`. A multi-user owner without the bypass grant has it forced back to `false`; an absent config is never materialized (a bare `copilot` is already safe). `COPILOT_HOME`, `COPILOT_ALLOW_ALL`, `COPILOT_PROVIDER_*` and `COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals (`COPILOT_PLAN_THEN_AUTOPILOT`), runs code with no approval row (`COPILOT_CLI_DIST_DIR`, the package caches, `COPILOT_EDITOR`, `COPILOT_PROVIDER_API_KEY_COMMAND`, skills, hooks) or sends the sign-in token to another host (`COPILOT_API_URL`, `COPILOT_GH_HOST`, ...): the list is in `stock.ts`, read from the 1.0.98 bundle. A variable a later Copilot release adds is not covered until it is listed there. |
| Model | `--model <id>`, sent only by an API caller that sets `copilotConfig.model` (the value must start with a letter or digit, like `resumeSessionId`). Nothing in the UI sets it: a saved custom endpoint sets `COPILOT_MODEL` instead (see below). |
| Resume | `--resume <value>`, sent only by an API caller that sets `copilotConfig.resumeSessionId` (Past Sessions does not). The value (an id, an id prefix or a session name) must be one plain word starting with a letter or digit: `--resume`'s value is optional, so `--resume --yolo` would otherwise make `--yolo` its own flag and bypass the clamp. |
| Continue | `--continue`, from **Past Sessions**: it resumes the most recent conversation Copilot has for that directory. Nothing sets it on a respawn. Skipped when a resume value is set. |
| Name | A tab name **you set yourself** is passed as `--name <tab name>`, so Copilot's own session list reads like that tab. An automatic name (`w1-myapp`, or an auto-named title) is not pinned. Copilot refuses `--name` with `--resume`/`--continue`, so it is dropped there and a continued session keeps the name it had. |
| Working / idle | The footer. A turn shows `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`); the composer is the `❯` between two rules. A tool waiting for approval replaces the composer and reads as idle. |
| Model detection | The right-hand field of the footer's last row, at rest and mid-turn. |
| Status probe | `GET /api/copilot/status`, plus an entry in the CLI-installed probes and `codeman doctor`. |
| MCP | The entry declares `mcpConfig` (`~/.copilot/mcp-config.json`, dialect `copilot-json`, relocated by `COPILOT_HOME`), so [MCP server sync](./cli-registry.md) treats it as a target and a source. `copilot mcp disable` is honoured (read from `settings.json`, never written). |
| Docker | The agent image installs `@github/copilot`. No credential directory is seeded (see below). |
### Your own endpoint (BYOK)
With custom model endpoints on, a saved endpoint is offered in the Run menu for Copilot and
launches straight onto it (no restart; the env is applied before the process starts):
| Codeman field | Variable |
| --- | --- |
| base URL (+ `/v1`) | `COPILOT_PROVIDER_BASE_URL` |
| key | `COPILOT_PROVIDER_API_KEY` |
| model | `COPILOT_MODEL` |
`COPILOT_PROVIDER_TYPE` is not set: it defaults to `openai`, which is what a Codeman custom
endpoint is (llama.cpp, vLLM, Ollama). All of these are privileged env keys.
Checked at the CLI level against a local OpenAI-compatible endpoint (1.0.95): it posted to
`/v1/chat/completions` with the key and the model name, needed no GitHub sign-in, and
`--resume <id> --model <m>` / `--continue --model <m>` resumed earlier sessions. Tool calls
through a custom endpoint were not exercised. Only keys are persisted (never values).
## Terminal behavior
Copilot is an alternate-screen TUI with mouse tracking on, so it needed one non-default
decision, the same one OpenCode needed:
- **Selection.** Left in, xterm reports a plain drag to the TUI instead of selecting, so
Auto Copy and copy-on-select silently did nothing (only Shift+drag selected). The entry is
`altScreen: 'strip-mux-and-mouse'`: the tracking DECSETs are stripped under tmux, a drag is
a local selection, and clicks still reach Copilot through the browser's hand-encoded tap
(`cliMouseTracking` is published as the server strips).
- **Wheel and touch.** Stripping the DECSETs also stops xterm encoding the wheel, so
`_shouldForwardWheelToApp` forwards it by hand as SGR reports while Copilot is tracking the
mouse; a phone swipe goes through the same gate. Shift+wheel scrolls Codeman's local
history. If the tracking flag is stale after a server restart, the gesture falls back to
PageUp/PageDown rather than going dead (Copilot pages its own transcript on those keys: three
PageUp moved a 140-line answer from lines 109-140 to 010-042).
- **Keys.** Keystrokes from `tmux send-keys` and `POST /api/sessions/:id/input` reach the
composer; Shift+Enter inserts a newline; shell tool calls complete inside tmux
(github/copilot-cli#4180 and #4223 report both failing on 1.0.70 to 1.0.74; neither
reproduces on 1.0.94).
- **Scrollbar.** Copilot draws a `┃` gutter at the right edge of every row; the response
reader strips it.
## `codeman agent` verbs
The three `CODEMAN_*` variables are exported into every pane, so the
[session-to-session verbs](../README.md#codeman-agent--session-to-session-verbs-in-every-cli-mode)
work from inside a Copilot tab and against Copilot workers:
- `spawn` waits for the composer. The ready mark is the footer hint `/ commands · ? help`, **not**
`❯`: the folder-trust dialog draws `❯ 1. Yes`, so the glyph would call a worker ready while it
is still asking whether to trust the folder. An untrusted folder therefore makes `spawn`
warn that the composer was not seen instead of claiming ready.
- `send --wait` resolves on `idle`. `--until stop` is refused (`400`, "no Claude Code hooks")
like every hook-less mode; use `idle`, `exit` or a `--match` marker.
- `read` returns the last answer. Copilot's output stream is absolute cursor moves rather
than lines, so the route reads the pane's **visible screen** (tmux `capture-pane`) and cuts it
with a per-CLI pane dialect (`src/web/response-viewer-transcript.ts`): `❯ <prompt> HH:MM`,
`● <answer>` with indented continuation, and the cwd / `Session: N AIC used` row, composer,
footer and scrollbar as chrome. Everything before the first prompt (banner, trust dialog) is
dropped, so a session that has not been asked anything answers nothing.
- `interrupt` sends a bare ESC; `rm` refuses itself.
## Docker cases
The agent image installs `@github/copilot`, and a docker case runs Copilot like any mode. **No
credential directory is seeded**: without a keyring the sign-in token is plain text in
`config.json`, and `CRED_STORES` (`src/docker-hosts.ts`) deliberately has no `.copilot` row.
Sign in inside the container (`copilot login`). Setting `COPILOT_GITHUB_TOKEN` for the session does
not reach it: a docker quick-start refuses `envOverrides`, and Copilot's `dockerExecEnvNames` is
empty, so a token in the server environment is not forwarded either.
## Remote SSH cases
A remote or Docker pane runs a bare `copilot` (`defaultRemoteCommandForMode`, pinned in
`test/location-overlay-commands.test.ts`), in Copilot's own Manual Approval mode. The
`copilotConfig` flags (`--yolo`, `--model`, `--resume`, `--continue`) are a local-session feature
and are not sent there, so there is nothing for the privileged-param clamp to do. A remote host
needs its own `copilot` on its own PATH and its own sign-in. This path has unit coverage for the
built command but was not exercised against a real remote host.
## What Codeman deliberately does NOT wire up
- **No login or token storage.** See Authenticate.
- **No bypass inferred.** `--yolo` is sent only by the Run button's `allowAll`, never
guessed from anything else, and is clamped for non-granted owners.
- **No `--api-key`-style flag on the command line.** Secrets travel as environment variables
through `tmux setenv`.
- **No Claude-only features.** Auto-resume on a usage limit, the Approvals Inbox, Read My Mind,
the Ralph tracker, subagent windows and the bundled agent skill are Claude-shaped.
## Known gaps
- `agent read` does not model tool-call rows: they open with the same `●` bullet and read as
part of the answer.
- `agent read` sees the visible screen, so an answer longer than one screen is its tail.
- Custom endpoints: tool calls through a BYOK endpoint were not exercised.
- Remote SSH was not exercised against a real remote host.
+1 -1
View File
@@ -204,7 +204,7 @@ trailing carriage return:
| Mode | Mechanism | Use when |
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. |
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
+2 -2
View File
@@ -265,8 +265,8 @@ siblings; no other external CLI mode qualifies. Two edges are worth repeating he
reports a turn that never ran, and leaves the prompt in a pane that was not yet
accepting input. Wait for the composer (`❯`) instead.
- **Wait on `stop`, not on the default signal set.** That set also carries `idle`,
which for every external CLI is inferred from output stabilization; a dsh TUI
that repaints rarely reads as idle mid-turn.
which for dsh is inferred from output stabilization; a dsh TUI that repaints
rarely reads as idle mid-turn.
## 6. The web UI as a tab
+4 -4
View File
@@ -228,10 +228,10 @@ whether your integration works, and the last one is what actually bites:
⚠️ **There is no readiness signal, and skipping readiness is the failure that looks
like success.** A session reports `idle` before its CLI has spawned, and a `claude`
worker in a brand-new case comes up on the CLI's **trust dialog**, which has a ❯
prompt of its own. Prompt it at that moment and the text lands in the dialog, the
`\r` does not get past it, and the session's startup `idle` lands inside the wait
window: the wait resolves on `idle` in a couple of seconds with `timedOut: false`,
indistinguishable from a finished turn. Wait for the pid, then wait for the
prompt of its own. Prompt it at that moment and the text lands in the dialog,
whose highlighted option is `No, exit`, so the prompt's `\r` quits the CLI. Codeman
holds the session's `idle` while the dialog is on screen, which makes waiting for
`idle` safe but still not a readiness signal. Wait for the pid, then wait for the
composer, answering the dialog only as the bounded fallback.
⚠️ **Answering it is not "press Enter".** Claude Code 2.1.252 dropped the options'
+6 -2
View File
@@ -121,8 +121,12 @@ the remote host, `grok login --device-auth` exists for exactly this.
## Known gaps
- **No idle/completion hook yet.** Idle detection falls back to output-stabilization
like the other external CLIs. Grok has a hooks system, so a Codeman hook POSTing to
- **No idle/completion hook yet.** The registry entry declares no `workDetect` prompt
marker or working line, which is what gives Codex, OpenCode, Gemini, Pi, OMP and
Antigravity a screen check. So Codeman calls a new grok pane idle 3 seconds after launch, and after
that it has only generic cues: Claude's `❯` marker, which grok does not print (see the
cron item below), and a braille spinner glyph. Working and idle are unreliable for
grok. Grok has a hooks system, so a Codeman hook POSTing to
`/api/hook-event` is the highest-value follow-up.
- **No response viewer.** Grok writes ACP JSONL sessions under
`~/.grok/sessions/<encoded-cwd>/<session-id>/updates.jsonl`; nothing reads them yet.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

+5 -3
View File
@@ -26,7 +26,9 @@ A GIF that leaves `docs/images/` stays in git history, so removing it does not s
| `tile-grid-20261009.gif` | README, Tile Grid section | the Tiles button opening six live sessions side by side, and closing them |
| `zerolag-demo-20260728.gif` | README, wiki `Input-And-Voice`, `packages/xterm-zerolag-input/README.md`, CHANGELOG, getcodeman.com docs | instant local echo next to slow server echo, on two phones |
| `codeman-tab-states-20261010.gif` | README, Tab Alerts section | annotated tab states: working, blocked on a question, idle |
| `codeman-skill-crt-20261010.gif` | README, agent skill section | one prompt to Claude Code spawning and driving two worker sessions through the skill |
| `codeman-skill-crt-20261010.gif` | README, agent skill section; wiki `Codeman-Skill` (getcodeman.com docs show the MP4 cut) | one prompt to Claude Code spawning and driving two worker sessions through the skill |
| `codeman-skill-grid-20261015.gif` | README, agent skill section (Step 2); wiki `Codeman-Skill` (getcodeman.com docs show the MP4 cut) | one prompt to a Claude Code lead, five Claude Code workers powering on as tiles in the grid |
| `codeman-skill-tabs-20261015.gif` | README, agent skill section (Step 3); wiki `Codeman-Skill` (getcodeman.com docs show the MP4 cut) | one prompt to a Claude Code lead, eight worker tabs powering on with lineage lines, then a click into a live worker |
| `subagent-demo-20260724.gif` | the "mission control" article on dev.to, Hashnode and Medium (hotlinked from `master`) | subagent windows fanning out |
Before removing one, search for hotlinks: `README*.md`, `docs/wiki/`, `packages/*/README.md`, `CHANGELOG.md`, the site (`pr/website/`), and published articles, which link `raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/<file>` and break silently.
@@ -36,12 +38,12 @@ On 2026-10-13, seven GIFs that no page embedded left `docs/images/` and moved in
### Adding or replacing a README GIF
1. Keep the master (the lossless MP4 or the full-size GIF) in `pr/media/clips/<feature>/`, or in the project folder it came from.
2. Commit only the size the README shows, named `<feature>-<YYYYMMDD>.gif`, and keep it lean: the current ones are 0.8 to 7 MB.
2. Commit only the size the README shows, named `<feature>-<YYYYMMDD>.gif`, and keep it lean: the current ones are 0.8 to 10 MB.
3. A replaced GIF leaves `docs/images/` in the same commit, unless something outside the repo hotlinks it (see above).
## Website media (getcodeman.com)
The site is built and deployed from `pr/website/` (gitignored) with `pr/deploy-site.sh`. Its videos and GIFs sit in `pr/website/assets/` because that directory is what gets uploaded: they are web cuts (1280 to 1600 px wide, high CRF, posters next to them) derived from masters in the library. `pr/site-notes.md` records how each one was cut and from which master. A few superseded files stay deployed on purpose, so links that point at them keep resolving.
The site is built and deployed from `pr/website/` (gitignored) with `pr/deploy-site.sh`. Its `/docs` pages are generated from `docs/wiki/`, and a wiki GIF that has an MP4 cut of the same run on the site is shown there as that video (the README and the GitHub wiki keep the GIF, since GitHub cannot autoplay inline video). Its videos and GIFs sit in `pr/website/assets/` because that directory is what gets uploaded: they are web cuts (1280 to 1600 px wide, high CRF, posters next to them) derived from masters in the library. `pr/site-notes.md` records how each one was cut and from which master. A few superseded files stay deployed on purpose, so links that point at them keep resolving.
## Release notes and the media kit (`design-assets` branch)
+2 -2
View File
@@ -161,8 +161,8 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
## 7. SSE Event Filtering
+1 -1
View File
@@ -244,7 +244,7 @@ RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignme
session.sendInput(task.prompt)
│
▼
writeViaMux() → tmux send-keys -l "prompt..." + Enter
writeViaMux() → tmux paste-buffer -p (prompt as one bracketed paste) + Enter
│
▼
Claude CLI receives prompt, executes, outputs results
+30 -1
View File
@@ -19,7 +19,7 @@ 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.
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)
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`
@@ -31,3 +31,32 @@ When user says "COM":
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.
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
## Security notices
A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it.
**Adding one**: append an entry to `notices`:
```json
{
"id": "opencode-console-breach-2026-10",
"severity": "warning",
"cli": "opencode",
"published": "2026-10-17",
"expires": "2026-12-31",
"title": "OpenCode Console breach: check your API keys",
"message": "A few short plain sentences, about 50 words at most: what happened and what to do.",
"link": { "label": "Read OpenCode's notice", "url": "https://..." },
"i18n": { "zh-CN": { "title": "...", "message": "...", "label": "..." } }
}
```
- `id` is stable and unique, and never reused: a browser that closed a notice remembers its id (`codeman-security-notices-seen` in localStorage), so a reworded notice under the same id is NOT shown again to anyone who closed it. Give a materially new warning a new id.
- `cli` (optional) is a registry CLI id. The notice is then shown only on a server where that CLI is installed and enabled; without `cli` it is shown to everyone.
- `published` and `expires` are inclusive `YYYY-MM-DD` days in the viewer's local time. Set `published` a day before the release ships, so the notice is already live in every timezone when it lands (a browser still on the previous day would otherwise take the plain update toast and see the notice later as a separate card). `expires` is what keeps an old notice from alarming a browser that first opens Codeman months later, so always set one.
- Keep `message` to about 50 words and do not repeat the card's chrome (the "Security notice" eyebrow and the title are already on it): on a phone the card shares the screen with the update toast's text, and a longer one has to be scrolled to reach its buttons. State only what the affected vendor has said.
- `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.
+5 -1
View File
@@ -90,7 +90,7 @@ Groups are always in this order, and a session is in exactly one of them:
| Group | Glyph | Means | Comes from |
| --- | --- | --- | --- |
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed) |
| **NEEDS YOU** | `⚠` | A permission or question dialog is blocking the agent | The approvals inbox (`permission_prompt` hooks, with the on-screen options parsed; for Codex, a dialog read off its screen) |
| | `✋` | Waiting for your next instruction, or errored | `idle_prompt`, or an errored session (equally something only a human clears) |
| **WORKING** | `✻` animating | A turn is running | The same working classification the web dashboard uses |
| **IDLE** | `○` | Live, but sitting there | |
@@ -158,6 +158,10 @@ the TUI says `that dialog is no longer on screen` rather than pressing a key int
live composer. The answer is scoped to the options the server parsed off the actual
frame, never to a guess.
Only a Claude dialog can be answered this way. A Codex or DeepSeek Harness item is an
alert: the server refuses the answer and the TUI shows its message saying to answer in
the terminal, so attach with `Enter` and answer it in the pane.
An idle prompt (`✋`) is not a dialog: there is nothing to approve, so `p` is the
reply path and the footer says `p reply` instead of `p prompt`.
+74 -7
View File
@@ -1,9 +1,9 @@
# Agent CLIs
Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking
Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
one, setting it up, and the differences that actually change how you work.
## The ten modes
## The eleven modes
| Mode | CLI | Get it |
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
@@ -16,6 +16,7 @@ one, setting it up, and the differences that actually change how you work.
| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
| **Terminal / Shell** | your `$SHELL` | Already installed. |
Any combination works, including all of them. The run mode is chosen per session from the
@@ -50,10 +51,10 @@ If a CLI is installed but a Run button for it never appears:
precisely to avoid this; a hand-written plist or unit will not.
3. Restart the server after installing a new CLI.
`pi`, `grok`, `omp` and `dsh` are additionally identity-probed rather than trusted by name:
`pi`, `grok`, `omp`, `copilot` and `dsh` are additionally identity-probed rather than trusted by name:
`pi` and `omp` are generic enough that something else on your PATH may answer to them,
`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`) that reports
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`) that reports
the path and version that actually resolved, so a misresolution is visible rather than
presenting as "the mode just does not work".
@@ -69,7 +70,7 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
@@ -184,6 +185,17 @@ Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of
in `~/.gemini/antigravity-cli/`, so the credential handling that applies to Gemini applies
to it as well.
- **Work detection reads its spinner row.** While a turn runs, `agy` animates an 8-dot braille
frame and a label one row above the composer (`⣯ Generating...`; `Working...` and
`Loading...` too), and its footer reads `esc to cancel`. Codeman matches that row, which is
what puts the activity indicator on the tab. The frames are not the braille set Claude and
Gemini use, which is why the tab used to look idle through a whole turn.
- **At rest the footer hint is the idle mark.** The composer's `>` is no good for that, since
the submitted prompt is echoed as `> <text>` as well. `? for shortcuts` is drawn only at
rest, and the turn's last repaint carries it. A shell call that waits for approval replaces
the composer and the footer hint is not drawn there, so the tab keeps reading as working
until you answer.
### Pi
Pi needs the opposite instincts from every other CLI here.
@@ -262,6 +274,59 @@ an OMP session that had started a turn showed as working for good.
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
### GitHub Copilot CLI
GitHub's `copilot`, installed with `npm install -g @github/copilot` (CLI management can run
that for you). Codeman recognises it only when `--version` answers `GitHub Copilot CLI`, and
`GET /api/copilot/status` reports the path and version that resolved.
**Signing in.** Codeman does not log it in: run `copilot` once and use `/login`, or sign in
inside a Codeman tab. Copilot keeps the token in the OS keychain when there is one; a headless
server has none, so it offers to save it as **plain text in `~/.copilot/config.json`**, which is
what a service ends up with. To keep a token out of a file, give the service
`COPILOT_GITHUB_TOKEN` (Copilot reads it first, then `GH_TOKEN`, `GITHUB_TOKEN`). The token is
never copied into a [Docker case](Docker-Cases): sign in inside the container.
**Run button and permissions.** Run starts `copilot --yolo` (allow every tool, path and URL),
the same bypass switch the other agent CLIs get, so an unattended session never stops on an
approval. A multi-user user without the bypass grant gets a plain `copilot`, which is
Copilot's own Manual Approval mode. Model, permission mode and the rest are also changeable
inside its TUI.
**Options and resume.** `--model` is sent only by an API caller that sets `copilotConfig.model`; the Run menu has no Copilot model picker (a saved custom endpoint sets `COPILOT_MODEL` instead). A session from
Past Sessions continues with `--continue`, which resumes the most recent Copilot conversation
in that directory. A tab name you set yourself is passed as `--name`, so Copilot's own session
list reads like that tab (an automatic `w1-myapp` name is not pinned); Copilot refuses `--name`
together with `--resume`/`--continue`, so a continued session keeps the name it had.
**Your own endpoint.** With [custom model endpoints](Custom-Model-Endpoints) on, a saved
endpoint appears in the Run menu for Copilot. It is launched with Copilot's BYOK variables
(`COPILOT_PROVIDER_BASE_URL` with the `/v1` an OpenAI route needs, `COPILOT_PROVIDER_API_KEY`,
`COPILOT_MODEL`), needs no GitHub sign-in, and applies at launch with no restart.
**Working and idle.** A turn shows `◉ Working` in the footer, which is what Codeman reads;
a tool waiting for your approval replaces the composer and reads as idle. The model name is
read from the footer too. Copilot has no hooks, so the `stop` and `blocked` wait signals are
not available: wait on `idle`, `exit` or an output marker.
**Selecting and scrolling.** Copilot turns mouse tracking on itself. Codeman strips that, so a
plain drag selects text (and Auto Copy works) and clicks still reach Copilot. The wheel, and a
swipe on a phone, scroll Copilot's own transcript; Shift+wheel scrolls Codeman's local history.
**MCP servers.** [MCP server sync](Settings-Reference) includes Copilot's
`~/.copilot/mcp-config.json` (relocated by `COPILOT_HOME`), as a target and a source.
**From another agent.** The `codeman agent` verbs work from inside a Copilot tab and against
Copilot workers: `spawn` waits for the composer (not the folder-trust dialog), `send --wait`
resolves on `idle`, and `read` returns the last answer from the pane's visible screen. Tool-call
rows are not modelled and read as part of the answer, and `--until stop` is refused as for
every hook-less mode.
**Not available.** Auto-resume on a usage limit, the Approvals Inbox, Read My Mind and the
Ralph tracker, as for the other non-Claude CLIs.
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-integration.md).
### Terminal / Shell
A plain shell in a tmux session. No agent, no hooks, no idle detection.
@@ -288,6 +353,7 @@ respawns. Which variables are accepted depends on the mode:
| Grok | `GROK_*`, `XAI_*` |
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
| OMP | `OMP_*` |
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
@@ -303,9 +369,10 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot** when you prefer that agent or that
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
hook-driven features.
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
see [Codex](#codex) above).
- **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one
non-Claude mode that reports idle, working and blocked to Codeman itself.
- **Pi** if you want a fast, unsandboxed agent and you understand what project trust does.
+166
View File
@@ -0,0 +1,166 @@
# The Codeman Skill
The codeman skill lets the agent inside a Codeman session drive Codeman itself. You ask in
plain English, and the agent starts worker sessions in any installed CLI, hands each one its
task, waits for them, reads their answers and cleans up after itself. No curl, no endpoint
names, no session ids in your prompt.
It is one skill for every agent CLI, in the `SKILL.md` format that Claude Code, Codex,
Gemini CLI, OpenCode, Grok, OMP and most other agent CLIs read. It lives in the repo at
[`skills/codeman`](https://github.com/Ark0N/Codeman/tree/master/skills/codeman).
## See it work
Three real runs on a live Codeman: real sessions, real agents, one typed prompt each.
Nothing on screen is mocked up; the long waits are sped up and labelled as time-lapse.
### Five workers in the tile grid
The lead Claude Code session gets `use codeman to start 5 claude workers, one per TODO item`,
and five Claude Code workers power on, one per item, with all six agents working at once.
![A real codeman skill run in the tile grid: the lead Claude Code session powers on as the only tile, the prompt use codeman to start 5 claude workers, one per TODO item is typed, then five Claude Code worker sessions power on as new tiles one after another with the CRT animation, and all six agents work at once](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-skill-grid-20261015.gif)
Sessions an agent starts do not join the grid by themselves. In this recording each worker
was put on the grid as it started, the way `Ctrl`+click or dragging its tab does it: see
[Tile Grid](Tile-Grid).
### Eight workers, eight tabs
The same prompt with eight workers, in the normal view. Every worker gets its own tab, and a
lineage line runs from the lead to each one, so you can see who spawned whom. Clicking a
worker's tab shows it at work.
![A real codeman skill run in the normal view: the prompt use codeman to start 8 claude workers, one per TODO item is typed, eight worker tabs power on left to right while lineage lines draw from the lead to each one, then clicking a worker tab shows a live Claude Code worker receiving its task](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-skill-tabs-20261015.gif)
### Two CLIs from one prompt
Workers do not have to run the same CLI as the lead. Here the prompt is
`use codeman to have a dsh worker on qwen build search and a claude one build checkout`: a
DeepSeek Harness worker on a local qwen model and a Claude Code worker power on side by side.
![A real codeman skill run: one short prompt typed into Claude Code, the tile grid powering on, then a DeepSeek Harness worker on a local qwen model and a Claude Code worker powering on as new tiles, with lineage lines from the lead to both](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/codeman-skill-crt-20261010.gif)
## Install it
Pick one route. The skill only acts inside a Codeman session, so a global install is safe.
| How | Command | Scope |
| ------------------ | ------------------------------------------------------------------------------- | ----- |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. The way to give it to pi. |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. |
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects it into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Off by default. |
Use the plugin **or** `codeman skill install`, not both: a Claude Code with both lists the
skill twice (`codeman` and `codeman:codeman`).
The web UI route skips pi, because a project skill makes pi ask to trust the folder at
startup. A pi or plain shell session uses the [`codeman agent`
commands](Driving-Codeman-From-An-Agent#the-codeman-agent-commands) instead, which are the
same verbs as shell commands.
### Remove it
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
Codeman-managed copy in that case, whichever CLI's directory it sits in). It never touches a
`skills/codeman` you wrote yourself.
Turning the web UI setting back off **does not remove copies it already injected**, because
a sweep at session start would pull the skill out from under other live sessions sharing
that directory. Remove them per case with `codeman skill uninstall --case <name>`.
## Then just ask
These prompts work as written:
| You say | What happens |
| ----------------------------------------------------------------------------------------- | ------------ |
| "What sessions are running right now?" | Lists them with name, mode and status. Read-only. |
| "Use codeman to start 5 claude workers, one per TODO item." | One worker per item, each handed its task, answers gathered as they finish. |
| "Start a shell worker on the `myapp` case, run the test suite, tell me if it passes." | Spawns, waits on a completion marker, reads the exit code, cleans up. |
| "Spin up 3 workers for lint, typecheck and tests, run them in parallel, report failures." | One session per task, all started first, then gathered as each finishes. |
| "Have a claude worker summarize `src/session.ts`, then close it." | Spawns, waits until the worker is ready, sends and waits, reads the answer, deletes the session. |
| "Have a DeepSeek Harness worker build search and a claude worker build checkout." | A mixed fleet: one worker per CLI, started in one go. |
| "Watch session w4 and tell me if it gets stuck on a permission prompt." | Blocks on the `blocked` signal and brings the question to **you**. |
Sessions the agent creates are deleted when it is done, so you can watch the tabs appear and
disappear in the dashboard while it works.
### A run, start to finish
> **You:** spin up 3 shell workers, run lint / typecheck / the frontend syntax check in
> parallel, and tell me which failed.
```text
lint -> 9f2d8e5f dispatched
typecheck -> aff9c691 dispatched 3 tabs appear in the dashboard
syntax -> be9f1f15 dispatched
lint DONE_lint_17909 rc=0
typecheck DONE_typecheck_3409 rc=0 gathered as each one finishes
syntax DONE_syntax_18501 rc=0
deleted 9f2d8e5f, aff9c691, be9f1f15 tabs disappear
```
The `DONE_<task>_<random>` strings are the
[split-marker trick](Driving-Codeman-From-An-Agent#the-split-marker-trick): a shell session
has no "turn finished" signal, so the skill waits for a marker in the output, typed in two
halves so that only the command's real output ever contains the joined string.
## Which workers it can drive
Any run mode installed on the machine can be a worker. Claude Code and DeepSeek Harness are
the two with real completion signals (Claude's from its hooks, DeepSeek's from the harness),
so the skill can wait for "turn finished" and read the answer as clean text. Every other CLI
and plain shell sessions are synchronized on an output marker instead, which works the same
from your side. See [Agent CLIs](Agent-CLIs) for the run modes.
With Claude Code workers, where the installed Claude Code supports it, the lead can also
message a worker directly instead of typing into its terminal.
## What it will and will not do
- **It self-gates.** Outside a Codeman session (`CODEMAN_MUX` unset) it refuses to act and
never guesses an API URL, so a global install costs an unrelated agent session nothing.
- **Unprompted, it may only** start sessions, prompt them, and delete the ones **it created
in that conversation, by exact id**, behind a guard that refuses to delete the agent's own
session.
- **It will not** answer another session's permission prompt on your behalf. It brings the
question to you instead.
- **Only when you ask, naming the target:** deleting a case (which erases a real directory
of your code), bulk kills, respawn, Ralph, cron, the orchestrator, and settings changes.
## When the agent is Codex
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 0.162 runs its commands in a shared background process, whose Codeman variables can
belong to the session that started it.
The skill checks for both and says so instead of acting.
## What is in the skill
| File | Contents |
| ---- | -------- |
| [`SKILL.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/SKILL.md) | Safety rules, the fast path (start N workers, task them, collect the answers) and the verb index. Always loaded. |
| [`reference/verbs.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/verbs.md) | Every verb in detail: readiness, send-and-wait, markers, interrupts, cleanup. Loaded on demand. |
| [`reference/recipes.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/recipes.md) | Worked flows: claude, DeepSeek Harness and shell workers, fan-out, watching a blocked worker, messaging. Loaded on demand. |
| [`reference/endpoints.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/endpoints.md) | Endpoint tables, error codes, the signals each mode emits, capacity limits. Loaded on demand. |
| [`reference/messaging.md`](https://github.com/Ark0N/Codeman/blob/master/skills/codeman/reference/messaging.md) | Talking to claude workers directly through Claude Code's cross-session messaging. Loaded on demand. |
Only `SKILL.md` sits in the agent's context; the references load when a job needs them, so a
quick run stays quick.
## Read next
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent`
commands, the MCP server, and the raw HTTP rules the skill is built on.
- [Tile Grid](Tile-Grid) - watching several workers side by side.
- [The Dashboard](The-Dashboard) - tabs, lineage lines and status dots.
- [Watching Agents Work](Watching-Agents-Work) - the subagents inside each session.
+1 -1
View File
@@ -120,7 +120,7 @@ Conventions for wiki pages:
- Images are referenced from the main repository over raw URLs rather than being copied into
the wiki.
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
- Label Claude-only behaviour every time it appears. Nine of the ten run modes are not
- Label Claude-only behaviour every time it appears. Ten of the eleven run modes are not
Claude.
## Conduct
+10 -7
View File
@@ -46,7 +46,8 @@ case. Each has a stable id, and that id is what the API, the wait primitives, an
event use.
Several sessions can share one case. That is the normal way to parallelize: three workers
in the same repo, three tabs, one case.
in the same repo, three tabs, one case. They then also share one folder; when the workers
edit and commit in parallel, give each its own checkout with [Git Worktrees](Git-Worktrees).
A session carries state the case does not:
@@ -58,7 +59,7 @@ A session carries state the case does not:
## Run mode
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, or `shell`. It is chosen at start and does not change afterwards; to
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, or `shell`. It is chosen at start and does not change afterwards; to
switch, start another session.
Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman
@@ -77,7 +78,7 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
This matters because it is a common source of confusion: Docker is **not** an eleventh run
mode. All ten run modes work in all three locations. A case is docker-backed or
mode. All eleven run modes work in all three locations. A case is docker-backed or
ssh-backed; a session is claude or codex or shell.
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
@@ -163,9 +164,11 @@ report events back: a permission prompt appeared, the turn finished, the agent w
task completed. Those events drive tab alerts, the Approvals Inbox, notifications, and the
wait primitives.
This is why some features are Claude-only. The one partial exception is DeepSeek Harness,
This is why some features are Claude-only. One partial exception is DeepSeek Harness,
whose terminal front door reports idle, working and blocked to Codeman over the harness's
own supervisor contract, so it gets the hook-driven signals without a hook file. The other
own supervisor contract, so it gets the hook-driven signals without a hook file. Codex gets
one of them from its screen: Codeman reads its approval, question and trust dialogs off the
pane and raises the same red "needs you" alert a `permission_prompt` hook raises. The other
CLIs have no equivalent, so for them Codeman falls back to watching terminal output, which
is coarser: it can see that something happened, not what it was.
@@ -177,7 +180,7 @@ See [Hooks And Integrations](Hooks-And-Integrations).
| --------------- | ---------------------------------------------------------------------------- |
| **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, shell. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator**| A phased plan driven across multiple agents. |
@@ -188,6 +191,6 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Read next
- [The Dashboard](The-Dashboard) - what the UI is showing you.
- [Agent CLIs](Agent-CLIs) - the ten run modes in detail.
- [Agent CLIs](Agent-CLIs) - the eleven run modes in detail.
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
+3 -2
View File
@@ -58,11 +58,11 @@ another device starts from its own history). The default model keeps its own **D
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
thumb.
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
**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
session's process ever starts. **Claude still restarts the harness's process in place** —
same tab, same conversation (`--resume`) — after a normal native launch, since that restart
is far less jarring for Claude than for the other seven, whose own TUI can fully
is far less jarring for Claude than for the others, whose own TUI can fully
reinitialize on a restart. Either way, every supported harness reads its endpoint config at
process start, never per turn, so there is no live hot-swap while a turn is running.
@@ -154,6 +154,7 @@ entry. "Launch anyway" is still there if you want to try regardless.
| Harness | Status |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub Copilot CLI** | Verified at the CLI level (1.0.95) against a local OpenAI-compatible endpoint: it posts to `/v1/chat/completions` with your key and model name and needs no GitHub sign-in. Tool calls through a custom endpoint were not exercised. |
| **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. |
+7 -1
View File
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
reproducible toolchain, and for the ability to pick the whole environment up and move it to
another machine.
A docker case is a **location overlay**, not a run mode. All ten run modes work inside a
A docker case is a **location overlay**, not a run mode. All eleven run modes work inside a
container. See [Core Concepts](Core-Concepts).
## One-time setup: the base image
@@ -118,6 +118,12 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha
container's history). OMP's `sessions/` is the exception and is shared read-write, because
Codeman reads it host-side for history and resume.
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
the container (`copilot login`). A `COPILOT_GITHUB_TOKEN` cannot be handed in instead: a docker
case refuses per-session environment overrides, and Copilot forwards no server variable into
the container.
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
with the `azure-devops` extension), off by default, and its git then uses them as credential
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
+14 -52
View File
@@ -12,58 +12,19 @@ everything else.
A skill in the `SKILL.md` format that Claude Code, Codex, Gemini CLI, OpenCode, Grok, OMP
and most other agent CLIs read. It teaches the agent the whole API, so you ask in plain
English instead of pasting endpoint documentation into prompts.
English instead of pasting endpoint documentation into prompts:
### Install it
```bash
codeman skill install # or: npx skills add Ark0N/Codeman --skill codeman -g
```
| How | Command | Scope |
| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, any skills-aware agent. The way to give it to pi. |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman`, then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager. `/plugin update codeman` follows releases. Pick this or `codeman skill install`, not both, or the skill is listed twice (`codeman` and `codeman:codeman`). |
| Bundled CLI | `codeman skill install` | Global, at `~/.claude/skills/codeman`. |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case. `--mode codex` (or any other run mode) writes it where that CLI looks for skills. |
| Web UI | **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** | Injects into each case when a session is created, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP. Not pi: a project skill makes pi ask to trust the folder at startup. Off by default. |
Then ask the agent in a Codeman session something like *"use codeman to start 5 claude
workers, one per TODO item"*. It starts the workers, hands each one its task, gathers the
answers and deletes the sessions it created.
`codeman skill uninstall [--case <name>]` reverses the CLI installs (with `--case`, every
Codeman-managed copy in that case), and never touches a `skills/codeman` you wrote yourself.
Two things to know when the agent is **Codex**: its default sandbox blocks network access
even to this machine, so the skill needs **Bypass approvals and sandbox** (App Settings,
Codex); and Codex 0.162 runs its commands in a shared background process, whose Codeman
variables can belong to the session that started it. The skill checks for both and says so
instead of acting.
### Then just ask
| You say | What happens |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| "What sessions are running right now?" | Lists them with name, mode, and status. Read-only. |
| "Start a shell worker on the `myapp` case, run the test suite, tell me if it passes." | Spawns, waits on a completion marker, reads the exit code, cleans up. |
| "Spin up 3 workers for lint, typecheck and tests, run them in parallel, report failures." | One session per task, all started first, then gathered as each finishes. |
| "Have a claude worker summarize `src/session.ts`, then close it." | Spawns, runs the readiness ladder, sends and waits, reads the answer, deletes the session. |
| "Watch session w4 and tell me if it gets stuck on a permission prompt." | Blocks on the `blocked` signal and surfaces the question to **you**. |
Sessions the agent creates get deleted when it is done. You can watch the tabs appear and
disappear in the dashboard while it works.
### What it will and will not do
- **It self-gates.** Outside a Codeman session it refuses to act and does not guess an API
URL, so a global install costs an unrelated Claude Code session nothing.
- **Unprompted, it may only** spawn sessions, prompt them, and delete ones **it created in
that conversation, by exact id**, behind a guard that refuses to delete the agent's own
session.
- **It will not** answer another session's permission prompt on your behalf. It surfaces the
question instead.
- **Deleting a case** (which erases a real directory of your code), bulk kills, respawn,
Ralph, cron, orchestrator, and settings writes all require you to ask, naming the target.
Turning the setting back off **does not remove already-injected copies**, because a
create-time sweep would yank the skill out from under other live sessions sharing that
directory. Remove them per case with `codeman skill uninstall --case <name>`.
The skill ships with the verb index always loaded, plus on-demand references for the verbs,
worked multi-worker recipes, endpoint tables, and cross-session messaging. It drives
**[The Codeman Skill](Codeman-Skill)** has the rest: recordings of real runs, every install
route (Claude Code plugin, skills CLI, per case, the web UI setting), prompts that work as
written, what it will and will not do on its own, and the notes for Codex. It drives
DeepSeek Harness workers the same way it drives Claude ones (`spawn_workers alpha
beta:deepseek` is a mixed fleet in one call), since those are the two modes with real
completion signals.
@@ -191,9 +152,9 @@ Read these before writing any code. Each one has cost somebody an afternoon.
output marker instead.
7. **Nothing reports "ready", so wait for it explicitly.** A new session answers
`{"signal":"exit","immediate":true}` until its PID exists, and that means *not started*,
not *crashed*. A Claude worker in a fresh case then sits on the CLI's trust dialog; prompt
it there and the wait resolves on idle in about two seconds looking exactly like a finished
turn, while your text sits stuck in the dialog.
not *crashed*. A Claude worker in a fresh case then sits on the CLI's trust dialog, whose
highlighted option is `No, exit`: prompt it there and your Enter quits the CLI. Codeman
holds `idle` while the dialog is on screen, but that is not a readiness signal either.
### Recipes
@@ -284,6 +245,7 @@ failing the spawn, because a cosmetic field must never break a worker.
## Read next
- [The Codeman Skill](Codeman-Skill) - the skill in action, its install routes and its limits.
- [HTTP API](HTTP-API) - the endpoint map and the envelope.
- [Hooks And Integrations](Hooks-And-Integrations) - events flowing the other way.
- [Watching Agents Work](Watching-Agents-Work) - seeing the fan-out in the UI.
+8 -5
View File
@@ -23,8 +23,8 @@ stores, or refreshes your credentials.
### Which agent CLIs does it support?
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness and
OMP, plus a plain shell, chosen per session. Claude is the reference mode and a few features
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
OMP and GitHub Copilot CLI, plus a plain shell, chosen per session. Claude is the reference mode and a few features
are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
### Does Codeman send my code or prompts anywhere?
@@ -207,10 +207,13 @@ cover them properly.
### Can agents start and supervise other agents?
Yes. Codeman ships an agent skill that lets an agent inside a session drive the HTTP API:
list sessions, spawn workers, send prompts, and block until a worker's turn finishes. It is
off by default and enabled per case.
list sessions, spawn workers in any installed CLI, send prompts, and block until a worker's
turn finishes. Install it once with `codeman skill install` or the Claude Code plugin, then
ask in plain English, for example *"use codeman to start 5 claude workers, one per TODO
item"*. The web UI setting that injects it into every new session's case is off by default.
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
See [The Codeman Skill](Codeman-Skill), with recordings of real runs, and
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the other routes.
### Can I run a case in a container?
+73
View File
@@ -0,0 +1,73 @@
# Git Worktrees
Sessions in one case share its folder. That is fine for one agent, but three agents in the
same checkout edit the same files, overwrite each other's builds, and commit each other's
half-finished work. A **git worktree** gives each of them a checkout of its own: same
repository, same history, a separate folder on a separate branch.
Codeman creates worktrees for a case whose folder is a git repository, starts sessions in
them, and removes them again when the work is done.
## Where they live
`<case>/.claude/worktrees/<name>`, on a new branch `worktree-<name>`. That is exactly where
Claude Code's own `claude --worktree <name>` puts them, so a worktree made in Codeman is one
Claude Code recognizes, and the other way round. Codeman's Recent Sessions and Session Manager
lists mark a session that runs in one with a `⑂ <name>` pill.
The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/`
(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the
`codeman` folder it writes when **Codeman Agent Skill** is on) to the repository's local
`.git/info/exclude` when git does not ignore them already. Without that, those files would make
every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is
never touched.
## Creating one and starting a session in it
1. Pick the case in the toolbar's case picker.
2. Open the picker again and choose **Git worktrees…** (on a phone: the case sheet's
**Worktrees** button).
3. Under **New worktree**, type a name (letters, digits, `.`, `_`, `-`, up to 64 characters).
4. Pick the CLI under **Start sessions with** (Claude Code, Codex, a shell, any installed run
mode).
5. **Create & start session**.
The worktree is created from the case's current commit, and the session opens in it as a tab
named `w1-<name>`. Under **Branch and start point** you can name the branch yourself or start
from another branch, tag or commit. An existing branch is never reused or reset: pick another
name.
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
## Removing one
Two ways, and both use `git worktree remove` **without** `--force`:
- **Remove…** on the worktree's row in the dialog.
- Closing a session that runs in a worktree: the Close Session dialog offers **Kill & remove
worktree**, which kills the session and then removes its worktree.
git refuses to remove a worktree that still holds uncommitted or untracked files, and Codeman
shows that refusal as git worded it and leaves everything in place. Commit, stash or delete the
work first, then remove again. A worktree a live session still runs in is refused too.
**Also delete its branch** deletes the branch with `git branch -d`, which keeps a branch whose
commits are not merged yet. The worktree is still removed, and the message says the branch was
kept.
If a worktree's folder was deleted by hand, its row says **missing** and offers only
**Remove…**: that clears the record git keeps of it, so the name can be used for a new worktree.
## What is not supported
- **Docker cases and remote (SSH) cases.** A container can write to its workspace, and creating
a worktree runs the repository's hooks and checkout filters on the Codeman machine, so git is
never run there. A remote case's repository is on another machine. For the same reason a
worktree that a Docker case uses as its workspace (or that holds one) is never removed from
here: remove the Docker case first.
- A case folder that is not the top of a repository (a subfolder of one), a bare repository, or
an empty repository with no commit yet.
- Worktrees outside `.claude/worktrees/` (made by hand with `git worktree add`) are listed, but
Codeman neither starts sessions in them from the dialog nor removes them.
The HTTP endpoints are in the [HTTP API](HTTP-API) reference (`/api/cases/:name/worktrees`).
+1 -1
View File
@@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain:
| ------------------- | -------- | --------------------------------------------------- |
| System | 56 | Status, settings, digest, updates, tunnel. |
| Sessions | 34 | Create, input, terminal, wait, last response, kill. |
| Cases | 34 | Create, link, clone, remote and docker cases. |
| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. |
| Files | 17 | Preview, edit, raw, attachments, path picker. |
| Orchestrator | 10 | Plans and phases. |
| Ralph | 9 | Loop control and configuration. |
+7 -4
View File
@@ -6,7 +6,7 @@
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps
Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to the browser, and keeps
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
subscription limit resets, runs jobs on a schedule, and shows every background subagent
live.
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
**Already running it**
- [Agent CLIs](Agent-CLIs) - the ten run modes, their setup, and which features are Claude-only.
- [Agent CLIs](Agent-CLIs) - the eleven run modes, their setup, and which features are Claude-only.
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
@@ -41,7 +41,8 @@ codeman web # then open http://localhost:3000
**Driving it from code**
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the bundled skill, worker sessions, wait primitives.
- [The Codeman Skill](Codeman-Skill) - one prompt, a whole crew: an agent starting and supervising worker sessions.
- [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) - the `codeman agent` commands, the MCP server, wait primitives.
- [HTTP API](HTTP-API) - the envelope, auth, the endpoint map, SSE events.
- [Hooks And Integrations](Hooks-And-Integrations) - events flowing back into Codeman.
@@ -66,6 +67,7 @@ codeman web # then open http://localhost:3000
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
| [Keyboard Shortcuts](Keyboard-Shortcuts) | What can I drive from the keyboard? |
@@ -101,6 +103,7 @@ codeman web # then open http://localhost:3000
| Page | What it answers |
| ----------------------------------------------------------------- | -------------------------------------------- |
| [The Codeman Skill](Codeman-Skill) | How do I get an agent to start and run workers for me? |
| [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) | How does an agent spawn and drive workers? |
| [HTTP API](HTTP-API) | What can I call, and what comes back? |
| [Hooks And Integrations](Hooks-And-Integrations) | How do I wire Codeman into something else? |
@@ -124,7 +127,7 @@ codeman web # then open http://localhost:3000
| OS | macOS or Linux. Windows works through WSL2. |
| Node.js | 22 or newer. |
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP. Plain shell sessions need none. |
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI. Plain shell sessions need none. |
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
+1 -1
View File
@@ -19,7 +19,7 @@ terminal into something that can notify you.
| `teammate_idle` | An agent-team member goes idle. | Team surfaces. |
| `task_completed` | A task finishes. | Task tracking, run summary. |
This is why several Codeman features are Claude-only. The one partial exception is DeepSeek
This is why several Codeman features are Claude-only. One partial exception is DeepSeek
Harness, whose terminal front door reports idle, working and blocked to Codeman over the
harness's own supervisor contract, so it gets the hook-driven surfaces without any hook
file. Codex gets one of them from its screen: Codeman reads its approval, question and
+12 -8
View File
@@ -27,12 +27,14 @@ keystroke echo. Idle now lands a few seconds after a turn genuinely ends.
There are several layers stacked on that: a completion message from the CLI, an AI check,
output silence, and token stability.
**For the other CLIs** it depends on what the CLI tells Codeman. Codex declares its own
prompt glyph and working line, so it gets the same screen check Claude does (before 1.26.1
every Codex session reported idle for its whole life). DeepSeek Harness reports idle,
working and blocked to Codeman itself, which is as precise as hooks. Everything else is
output stabilization: the session is idle when output stops changing. Coarser, and it is
why the features further down this page are Claude-only.
**For the other CLIs** it depends on what the CLI tells Codeman. Codex, OpenCode, Gemini
CLI, Pi, OMP and Antigravity each declare their own prompt marker and working line, so they
get the same screen check Claude does (before 1.26.1 every Codex session reported idle for
its whole life). DeepSeek Harness reports its end of turn and its dialogs to Codeman itself,
which is as precise as hooks; its working and idle display still comes from its output.
Grok Build declares neither. Codeman calls a new grok pane idle 3 seconds after launch, and
after that it has only generic cues to go on (Claude's `❯` prompt marker, a braille spinner
glyph), which grok may not draw, so its working and idle status is unreliable.
## The Respawn Controller
@@ -161,8 +163,10 @@ run summary and lifecycle log carry the detail.
- **The update prompt is what makes a reset survivable.** After `/clear`, everything the
agent knows comes from that summary and the project files. A vague update prompt produces
a vague next cycle.
- **Non-Claude sessions can respawn**, but with output-based idle detection and no
usage-limit auto-resume.
- **Non-Claude sessions can respawn**, but with no usage-limit auto-resume, and with
output-based idle detection except on DeepSeek Harness, whose own end-of-turn report
reaches the respawn controller the way Claude's `stop` hook does while its status bridge
is on (a local session, by default).
- **Do not run respawn on a session you are actively typing in.** It will send prompts
underneath you.
+12 -2
View File
@@ -67,6 +67,13 @@ default. See [Tile Grid](Tile-Grid).
**App Settings → Shortcuts.** Bindings live in a registry with per-user overrides, so a
rebind is stored as an override on top of the default rather than replacing the table.
Previous Session (`Alt+[`) and Next Session (alternate, `Alt+]`) are rows like any other,
so they can be rebound too.
A chord another action already uses is refused, and the message names that action. The same
check runs when you re-enable a disabled shortcut and when you press Reset: if its chord was
given to something else in the meantime, it stays as it was. `Alt+1` to `Alt+9`,
`Shift+Enter`/`Ctrl+Enter` and `Ctrl+V`/`Cmd+V` are reserved.
Two things are deliberately not rebindable:
@@ -79,8 +86,11 @@ Two things are deliberately not rebindable:
The terminal sees keystrokes before the app does. Any chord the app claims has to also be
swallowed at the terminal layer, or xterm writes the control byte into the session as well
as triggering the action. If you rebind something to a chord the terminal cares about
(`Ctrl+D`, say), expect the CLI to see it too.
as triggering the action. A chord **you** rebind an action to is kept out of the terminal
too, so rebinding Previous Session to `Alt+J` switches the session and sends nothing to the
CLI. The built-in defaults are deliberately not held back this way: `Ctrl+L` clears the
screen and also reaches the CLI, which redraws on it. Binding an action back onto its own
default behaves like the default.
`Alt+1` through `Alt+9` are matched on **physical key position** rather than the character
produced, so macOS Option layouts that produce `¡™£` still switch tabs.
+19 -13
View File
@@ -43,8 +43,9 @@ as the keyboard appearing.
Tapping the "C" logo gives a session overview rather than a welcome page:
1. **NEEDS YOU** first: sessions blocked on a question, with answer strips so you can
resolve them without opening the session.
1. **NEEDS YOU** first: sessions blocked on a question, with answer strips (Approvals Inbox
on) so you can resolve a Claude dialog without opening the session. A Codex or DeepSeek
Harness row offers **Answer in terminal** instead, which opens the session.
2. **CURRENT SESSIONS** with live status.
3. **PAST SESSIONS**, resumable.
@@ -66,7 +67,8 @@ autocorrect: Enter adds a new line, and only Send delivers the text, as one past
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,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Image button attaches photos or videos and puts their paths into the draft.
is parked. The editor's Attach button attaches photos, videos or documents and puts their paths
into the draft.
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
editing the last queued message and walking the prompt stack.
@@ -103,12 +105,16 @@ into the Run dropdown.
## The Attach button
The toolbar's **Attach** button (the picture icon beside the mic) is the keyboard-down way to
hand the agent a photo or a video. One tap opens the camera and photo library sheet, nothing
else: no editor, no keyboard. The chosen files upload and their paths land on the prompt, so
you then tap the terminal, write the rest of your message and press Enter. Videos (mp4, webm,
mov, m4v, ogv) upload as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the server changes it), so a long 4K clip takes a moment
on the way up.
hand the agent a photo, a video or a document. One tap opens the system's sheet (camera,
photo library, or a file from Files or a cloud drive), nothing else: no editor, no keyboard.
The chosen files upload and their paths land on the prompt, so you then tap the terminal,
write the rest of your message and press Enter. Videos (mp4, webm, mov, m4v, ogv)
upload as recorded, up to 1 GB each by default (`CODEMAN_MAX_PASTE_VIDEO_BYTES` on the
server changes it), so a long 4K clip takes a moment on the way up. Documents are PDFs,
Office files (docx, pptx, xlsx), zips and plain-text formats such as txt, md, csv and json;
a document keeps the plain-letters part of its name in the saved file, so the agent and you
can tell the uploads apart. See [Working With Files](Working-With-Files) for where they land and how long they
stay.
If the keyboard was up when you tapped Attach, iOS closes it to show the photo sheet and does
not bring it back, so the tap on the terminal is how you get it again. That is the system's
@@ -120,7 +126,7 @@ anything you had already typed, so you can write in front of a path, fix a word,
**Use terminal keyboard** hands a single-line message back the other way, onto the prompt,
so neither surface holds a half of your message. A multi-line message cannot make that trip
(the prompt is a single line), so it stays parked as a draft and the dot on the Compose key
shows it is waiting. The Compose editor's own Image button remains for the times you are
shows it is waiting. The Compose editor's own Attach button remains for the times you are
already writing in the editor.
## Tapping, links and copying
@@ -162,9 +168,9 @@ phone, and it is the main reason the feature exists.
## Notifications
Push notifications reach you with no tab open, and with the Approvals Inbox on they carry
**Approve** and **Deny** buttons handled by the service worker, so you can unblock an agent
from the lock screen.
Push notifications reach you with no tab open, and with the Approvals Inbox on, those for a
Claude permission prompt carry **Approve** and **Deny** buttons handled by the service
worker, so you can unblock a Claude agent from the lock screen.
Setup in [Notifications And Approvals](Notifications-And-Approvals).
+30
View File
@@ -60,6 +60,36 @@ Admins see everything.
Ownership threads through every list endpoint, the session lookup helper, the WebSocket
layer, and file previews. A user cannot address another user's session even by id.
## Server-wide settings are admin only
There is one set of server settings (`~/.codeman/settings.json`) for everyone: the feature
switches, the run mode, respawn presets, the case order, the Cloudflare tunnel, the default
models. Every user can **read** them, but only an **admin** can change them. For a regular
user the settings write routes answer `403 FORBIDDEN` (see the
[HTTP API](HTTP-API) and [`docs/api-reference.md`](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md)).
A regular user reads them without what is the admin's: the Deepgram key an admin saved for
voice dictation, and the admin's case names (case order, last used case).
In the browser this means:
- **App Settings → Save** still keeps a regular user's per-device settings (skin, fonts,
header buttons, keyboard bar and the rest of the per-device keys) on that device. If they
also changed a server-wide setting, one notice says it can only be changed by an admin,
and the setting keeps the server's value.
- A regular user's run-mode pick and respawn presets are their own: kept in that browser
under their name, not sent to the server, and still theirs on the next visit (over what
an admin set). Their last used case is not recorded server-wide.
- Reordering cases and switching the tunnel on or off are admin actions.
- If the page could not tell yet who is signed in (a slow or failed sign-in check), the
server's refusal is read the same way: the same notice, and nothing more is sent.
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.
Per-user server settings are not part of this version.
## Safer defaults for regular users
Non-admins get tighter defaults, and lifting them is an explicit per-user grant:
+20 -14
View File
@@ -13,7 +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 |
| Approvals Inbox | One queue across every session | 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 |
@@ -37,8 +37,10 @@ For Claude sessions, these come from Claude Code's hooks and are precise about *
session stopped; DeepSeek Harness sessions report the same states themselves. Codex has no
hooks, but its dialogs are distinctive enough to read off the screen, so a Codex session
waiting on an approval, a question or the "Trust this folder?" prompt turns red too, and
clears a few seconds after you answer. For the other CLIs there are no hooks, so you get the
coarser output-based signal.
clears a few seconds after you answer. For the other CLIs there are no hooks and no dialog
reading, so you get only the coarser working and idle state: read off the screen for
OpenCode, Gemini CLI, Pi, OMP and Antigravity, and unreliable for Grok Build, which
declares nothing Codeman can read it from.
## Window title and OS notifications
@@ -116,9 +118,9 @@ front door reports its prompts to Codeman, and Codex sessions, whose dialogs Cod
off the screen.** DeepSeek and Codex cards are alerts: they say "Answer in the terminal" and
carry no answer buttons, because Codeman cannot verify a keystroke into those dialogs.
One queue of every prompt currently waiting on a human, across all your sessions, answerable
in place. When you have eight workers running, this is the difference between checking eight
tabs and checking one list.
One queue of the prompts currently waiting on a human, across all your sessions, with the
Claude ones answerable in place. When you have eight workers running, this is the difference
between checking eight tabs and checking one list.
Turn it on in **App Settings**. Surfaces:
@@ -139,17 +141,20 @@ Behaviour worth knowing:
sending, and refuses with a conflict if the dialog is no longer there. Otherwise your
keystroke would land in the composer as stray text.
- **Permission and question items clear only on definitive signals**: the turn ending, the
dialog completing, an answer, a supersede, the session exiting, or a 12 hour timeout. They
do not clear on a heuristic "looks busy again" signal, because that signal is wrong often
enough to lose a real prompt.
dialog completing, an answer, a supersede, the session exiting, or a 12 hour timeout. A
Codex item also clears a few seconds after its dialog leaves the screen, and a DeepSeek
Harness item when the harness reports it is working again. They do not clear on a
heuristic "looks busy again" signal, because that signal is wrong often enough to lose a
real prompt.
- **In memory only.** Restarting the server clears the queue; the prompts themselves are
still sitting in the sessions.
### Approve and Deny from the notification
With the inbox enabled, push notifications carry **Approve** and **Deny** buttons. Those are
handled by the service worker directly, so they work with no tab open: tap Approve on a
locked phone and the agent continues.
With the inbox enabled, push notifications for a Claude permission prompt carry **Approve**
and **Deny** buttons. Those are handled by the service worker directly, so they work with no
tab open: tap Approve on a locked phone and the agent continues. A Codex or DeepSeek Harness
notification carries no Approve or Deny, since those dialogs are answered in the terminal.
With the inbox off, the buttons are stripped from the notification payload entirely rather
than being shown and failing.
@@ -189,8 +194,9 @@ current sessions, then past ones. Rows use the same language as the tab strip: a
when fine, pulsing while working, yellow when waiting for input, red when a question is
pending.
Answer strips let you resolve a prompt straight from the home screen without opening the
session.
With the Approvals Inbox on, answer strips let you resolve a Claude permission or question
dialog straight from the home screen without opening the session. A Codex or DeepSeek
Harness strip offers **Answer in terminal** instead, which opens the session.
## The Away Digest
+1 -1
View File
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
remote host.
Like Docker, this is a **location overlay** on a case, not a run mode. All ten run modes
Like Docker, this is a **location overlay** on a case, not a run mode. All eleven run modes
work remotely. See [Core Concepts](Core-Concepts).
## Why bother
+7 -2
View File
@@ -29,6 +29,10 @@ The practical rule: **appearance and input are per device, behaviour is shared.*
did not follow you, it is in one of the first two rows, and you change it again on that
device.
In [Multi-User Mode](Multi-User-Mode) the server's settings are shared by every user, so
only an **admin** can change them. A regular user's **Save** keeps their per-device settings
as usual and tells them that server-wide settings can only be changed by an admin.
## App Settings
### Updates
@@ -146,7 +150,8 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Allowed Tools | The list used by the explicit mode. |
| Ralph / Todo Tracker | Enables the Ralph loop surfaces. |
| Agent Teams | Experimental teams. Also needs the CLI's own environment flag. |
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). |
| Pin tab names on the CLI | Launches every Claude session with `--name` set to its tab name, so Claude Code's prompt label and `/resume` list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Synced, off by default; applies from the next session start or respawn. |
| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). Off by default. See [The Codeman Skill](Codeman-Skill). |
| Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. |
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. |
@@ -217,7 +222,7 @@ Some things are configured before the server starts, not in the UI:
| `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. |
| `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). |
| `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. |
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image. 50 MB by default. |
| `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. |
| `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. |
| `CODEMAN_MAX_REMOTE_FILE_SSH` | Concurrent ssh reads for files in remote cases. 4 by default. |
| `CODEMAN_PATH_PROBE_TIMEOUT_MS` | How long a linked case's folder may take to answer before it is shown as unreachable. 1500 ms by default; raise it for a slow but healthy mount. |
+9
View File
@@ -202,6 +202,15 @@ Both wait about 2.5 seconds before appearing, so a deploy that restarts the serv
flash a warning at you every time. If the browser reports itself offline, the grace period
is skipped.
**The transport readout.** With a session open, the header shows how its terminal is
connected: `WS` (WebSocket, the normal case), `WS…` (connecting or reconnecting) or `HTTP`
(the WebSocket is unavailable and input goes over HTTP). If typed or pasted input is backing
up faster than it is acknowledged, the readout grows a suffix such as `WS · 1.2KB queued`.
That suffix appears only after the backlog has stayed up for about a second, so a paste or a
fast burst that clears at once does not make the pill widen and shrink. (It is part of the
text line, so you see it in the Compact and As before header styles; Tiles hides that line and
shows `WS live`, and phones hide it too.)
There is also a watchdog for the case where the connection stops delivering without
erroring. If the server's heartbeat stops arriving, Codeman reconnects on its own rather
than sitting on a green dot showing frozen data.
+8 -3
View File
@@ -116,9 +116,14 @@ seconds in. Current versions confirm against the actual screen before believing
### A session is stuck showing busy
For non-Claude CLIs, idle detection is output-based and coarser by necessity: those CLIs
expose no hooks. A session that has genuinely gone quiet will settle. If it never does,
interrupt it (`Ctrl+C` with nothing selected).
For Grok Build the busy and idle status is unreliable: it exposes no hooks and declares no
working line, so after launch Codeman has only generic cues to go on (Claude's `❯` prompt
marker, a braille spinner glyph), which it may not draw. Codex, OpenCode, Gemini CLI, Pi,
OMP and Antigravity get the same screen check Claude does, so a session of theirs that has
genuinely gone quiet will settle. An Antigravity session waiting on a shell-command approval
("Run this command?") shows busy until you answer it: `agy` draws nothing Codeman reads as
"at rest" while that list is up. If a session never settles, interrupt it (`Ctrl+C` with
nothing selected).
### The agent asks about bypass permissions every time
+32 -8
View File
@@ -168,17 +168,41 @@ blocks sensitive trees. In multi-user mode a non-admin gets only their own user
root, because per-user spaces live inside the home directory and a home-directory root would
expose everyone.
## Images into a session
## Files into a session
Paste from the clipboard or drag and drop straight onto the terminal. The image is written
Paste from the clipboard or drag and drop straight onto the terminal. The file is written
where the agent can read it and the reference is inserted into your prompt. On a phone, the
toolbar's Attach button opens the camera or photo library, videos included, and puts the path
on your prompt, where it behaves like text you typed; the Image button inside the Compose
editor does the same into the editor. Dropping a video onto the terminal works the same way.
toolbar's Attach button opens the camera, photo library or files, and puts the path on your
prompt, where it behaves like text you typed; the Attach button inside the Compose editor
does the same into the editor. Dropping a video or a document onto the terminal works the
same way.
HEIC images from an iPhone are converted to JPEG on the way in.
Videos (mp4, webm, mov, m4v, ogv) are stored as recorded, up to 1 GB each by default
(`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than through server memory.
Images (png, jpg, gif, webp, bmp, heic) are re-encoded on the device before they go up (a GIF
goes as is, so it keeps its frames), and HEIC from an iPhone is converted to JPEG. Videos (mp4, webm, mov, m4v, ogv) are stored as recorded,
up to 1 GB each by default (`CODEMAN_MAX_PASTE_VIDEO_BYTES`); they stream to disk rather than
through server memory. Documents are the office formats (pdf, docx, pptx, xlsx, zip) and the
text family (txt, md, csv, tsv, json, log, yaml, yml, xml). Each is checked on the way in,
against its file signature for the binary formats and against its encoding for text (UTF-8,
no NUL bytes), so a script renamed to `.pdf` is refused while a `.txt` can hold any text at
all. A text file in another encoding is refused with a message saying it is not UTF-8 (or that
it is UTF-16, which Excel's "Unicode Text" and Notepad's "Unicode" produce); save it as UTF-8
(in Excel, "CSV UTF-8") and attach it again. Files with script or page extensions (sh, py,
js, html) are not accepted, since a prompt upload is something for the agent to read, not
something for Codeman to store and serve. A document keeps a trimmed copy of its original
name, letters, digits, dot, dash and underscore with anything else turned into a dash, so
`Q3 report.pdf` is findable as `paste-<time>-<id>-Q3-report.pdf`; a name made entirely of
other characters saves without one. Uploads are refused for remote (SSH) sessions, whose
working directory is on the other host.
Uploads land in `.codeman-uploads/` inside the case, next to the code the agent is working
on, because that is the one path a local agent and a Docker case (whose workspace is mounted
at the same absolute path) both see the same way. The first upload writes a `.gitignore` of `*` into `.codeman-uploads/`, so the
directory ignores itself and your repository never picks the files up, while your own ignore file is
left alone; a `.gitignore` you already had there is never overwritten. Files older than seven
days are swept hourly, and the directory goes when the last session of the case is closed (a
session that is only detached keeps it). Earlier releases wrote to `.claude-images/`; that
directory is still swept and cleaned up the same way for one release so a case in use before
the move is not left with stray files.
## Generated artifacts
+2
View File
@@ -15,6 +15,7 @@
- [Agent CLIs](Agent-CLIs)
- [Custom Model Endpoints](Custom-Model-Endpoints)
- [Working With Files](Working-With-Files)
- [Git Worktrees](Git-Worktrees)
- [Input And Voice](Input-And-Voice)
- [Mobile Guide](Mobile-Guide)
- [Keyboard Shortcuts](Keyboard-Shortcuts)
@@ -42,6 +43,7 @@
**Automation**
- [The Codeman Skill](Codeman-Skill)
- [Driving It From An Agent](Driving-Codeman-From-An-Agent)
- [HTTP API](HTTP-API)
- [Hooks & Integrations](Hooks-And-Integrations)
+13 -13
View File
@@ -168,19 +168,19 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
# commit as the script itself. Nothing fetched at install time is ever executed; there is
# no network refresh of these arrays. See cli_catalog_select_platform below.
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0)
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh')
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp')
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp')
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8)
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1)
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp")
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41)
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6)
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot')
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot')
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1)
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0)
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli')
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'npm install -g @github/copilot')
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'npm install -g @github/copilot')
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot')
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1)
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot")
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
# <<< END GENERATED CLI CATALOGUE
# ============================================================================
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.41.1",
"version": "1.43.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.41.1",
"version": "1.43.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.41.1",
"version": "1.43.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",
@@ -39,7 +39,7 @@
"check:public-assets": "node scripts/check-public-assets.mjs",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset",
"version-packages": "changeset version && node scripts/sync-plugin.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"version-packages": "changeset version && node scripts/generate-whats-new.mjs && prettier --write src/web/public/whats-new.json && node scripts/sync-plugin.mjs && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json",
"knip": "npx --yes knip@latest --config config/knip.json",
+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 only inside a Codeman-managed session.",
"version": "1.41.1",
"version": "1.43.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+21 -17
View File
@@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -98,8 +98,11 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -108,7 +111,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -143,15 +146,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one token
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -167,7 +170,7 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -185,7 +188,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -195,7 +198,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -396,7 +399,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -407,10 +410,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4
CODEMAN_PREAMBLE=1.42.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -461,7 +464,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -549,8 +552,9 @@ That is true of no other non-claude mode, and it is worth knowing why: the DeepS
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
+14 -11
View File
@@ -1,4 +1,7 @@
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -7,7 +10,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -42,15 +45,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one token
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -66,7 +69,7 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -84,7 +87,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -94,7 +97,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -295,7 +298,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -306,4 +309,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4
CODEMAN_PREAMBLE=1.42.0
@@ -237,7 +237,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
@@ -356,9 +356,9 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -486,7 +486,7 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
@@ -634,8 +634,8 @@ Three bounded long-polls. Shared semantics:
|--------|---------|---------------|
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer them in the terminal, never through the inbox) |
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), and `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer both in the terminal, never through the inbox) |
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -105,11 +105,11 @@ CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in a
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: by default only a name the user chose
is pinned (`Session.cliPinnedName`; the opt-in `pinCliSessionName` setting pins every tab name, and a rename then holds only until the next turn), because `--name` is also the conversation's
`/resume` title and terminal title and suppresses Claude's own generated title. A
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
and an auto name are NOT passed, so such a worker's peer name is derived; use
and an auto name are NOT passed (with `pinCliSessionName` off, the default), so such a worker's peer name is derived; use
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
only unsafe characters is dropped), and the docker/remote builders never see it at all
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
@@ -376,7 +376,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no**
@@ -430,7 +430,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -516,7 +516,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
@@ -699,7 +699,7 @@ The shape, each step verified live (probes, failure modes and safety detail in
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
pinned, so it lists derived); older setups list a name derived from the case folder.
pinned by default, so it lists derived); older setups list a name derived from the case folder.
No row = messaging is off for that worker (it is feature-flagged even on matching
CLI versions, observed live): fall back to the HTTP recipes without complaint.
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
// Writes src/web/public/whats-new.json for the version in package.json from CHANGELOG.md.
// Runs as part of `npm run version-packages`, so every release carries its own notes and the
// UI can announce them once per version without anyone remembering to.
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildWhatsNew } from './lib/whats-new.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const { version } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
const changelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8');
const notes = buildWhatsNew(changelog, version);
if (notes.highlights.length === 0) {
console.warn(`whats-new: no bold highlights found for ${version}; the in-app announcement will be skipped`);
}
writeFileSync(resolve(root, 'src/web/public/whats-new.json'), JSON.stringify(notes, null, 2) + '\n');
console.log(`whats-new: ${version} -> ${notes.highlights.length} highlight(s)`);
+42
View File
@@ -0,0 +1,42 @@
/**
* Pulls the headline of each change out of one version's section of CHANGELOG.md.
*
* Release notes here lead each change with a bold phrase ("**Tab layouts (#538, optional).** ..."),
* so a highlight is that phrase. The Thanks block is credit, not a feature.
*/
const MAX_HIGHLIGHTS = 8;
/** The text between `## <version>` and the next `## ` heading, or '' when absent. */
export function versionSection(changelog, version) {
const lines = changelog.split(/\r?\n/);
const start = lines.findIndex((l) => l.trim() === `## ${version}`);
if (start === -1) return '';
let end = lines.findIndex((l, i) => i > start && /^## /.test(l));
if (end === -1) end = lines.length;
return lines.slice(start + 1, end).join('\n');
}
// 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 =
/^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */
export function extractHighlights(section) {
const out = [];
for (const line of section.split('\n')) {
// `changeset version` prefixes the first line of a committed changeset with its commit
// ("- 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();
if (!text || SECTION_LABELS.test(text)) continue;
if (!out.includes(text)) out.push(text);
}
return out.slice(0, MAX_HIGHLIGHTS);
}
export function buildWhatsNew(changelog, version) {
return { version, highlights: extractHighlights(versionSection(changelog, version)) };
}
+21 -17
View File
@@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -98,8 +98,11 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -108,7 +111,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -143,15 +146,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one token
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -167,7 +170,7 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -185,7 +188,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -195,7 +198,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -396,7 +399,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -407,10 +410,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4
CODEMAN_PREAMBLE=1.42.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -461,7 +464,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -549,8 +552,9 @@ That is true of no other non-claude mode, and it is worth knowing why: the DeepS
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
+14 -11
View File
@@ -1,4 +1,7 @@
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -7,7 +10,7 @@ SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
envval() { sed -n "s/^\(export \)\{0,1\}${1}=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
@@ -42,15 +45,15 @@ delete_session() {
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one token
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -66,7 +69,7 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
@@ -84,7 +87,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
t=$("${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
@@ -94,7 +97,7 @@ _composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
local sid="${1}" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
@@ -295,7 +298,7 @@ sendwait() {
last_text() {
local t="" prev="${2:-}"
for _ in $(seq 1 15); do
t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
t=$("${CURL[@]}" "$API/api/v1/sessions/${1}/last-response" | jq -r '.data.text // empty')
[ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; }
sleep 1
done
@@ -306,4 +309,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.33.4
CODEMAN_PREAMBLE=1.42.0
+8 -8
View File
@@ -237,7 +237,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
@@ -356,9 +356,9 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -486,7 +486,7 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
@@ -634,8 +634,8 @@ Three bounded long-polls. Shared semantics:
|--------|---------|---------------|
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer them in the terminal, never through the inbox) |
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), and `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer both in the terminal, never through the inbox) |
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
+5 -5
View File
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -105,11 +105,11 @@ CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in a
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: by default only a name the user chose
is pinned (`Session.cliPinnedName`; the opt-in `pinCliSessionName` setting pins every tab name, and a rename then holds only until the next turn), because `--name` is also the conversation's
`/resume` title and terminal title and suppresses Claude's own generated title. A
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
and an auto name are NOT passed, so such a worker's peer name is derived; use
and an auto name are NOT passed (with `pinCliSessionName` off, the default), so such a worker's peer name is derived; use
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
only unsafe characters is dropped), and the docker/remote builders never see it at all
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
+2 -2
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
+4 -4
View File
@@ -376,7 +376,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no**
@@ -430,7 +430,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -516,7 +516,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
@@ -699,7 +699,7 @@ The shape, each step verified live (probes, failure modes and safety detail in
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
pinned, so it lists derived); older setups list a name derived from the case folder.
pinned by default, so it lists derived); older setups list a name derived from the case folder.
No row = messaging is off for that worker (it is feature-flagged even on matching
CLI versions, observed live): fall back to the HTTP recipes without complaint.
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
+6 -4
View File
@@ -7,10 +7,12 @@
* used to change only the tab, so `/resume` kept listing the old name.
*
* Appending the row is enough for a pane that was spawned WITHOUT `--name`
* (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that
* process holds no title of its own and never writes one back. A process that
* WAS spawned with `--name` re-appends its in-memory title after each turn, so
* there the new title holds from the next spawn, which pins the new name.
* (every placeholder- or auto-named tab, unless the opt-in `pinCliSessionName`
* setting is on, see `Session.cliPinnedName`): that process holds no title of
* its own and never writes one back. A process that WAS spawned with `--name`
* (every tab with `pinCliSessionName` on) re-appends its in-memory title after
* each turn, so there the new title holds only until the next turn, then from
* the next spawn, which pins the new name.
*
* @module claude-session-title
*/
+4
View File
@@ -20,6 +20,7 @@
export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-plain'
| 'model-pi'
| 'id'
| 'id-dotted'
@@ -43,6 +44,9 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
model: /^[a-zA-Z0-9._\-/]+$/,
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
// 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._\-/]*$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
+5 -3
View File
@@ -295,7 +295,7 @@ const capabilitiesSchema = z
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
echo: echoSchema,
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.object({ mode: z.enum(['never', 'version-gated', 'always']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
@@ -464,8 +464,10 @@ const capabilitiesSchema = z
)
.max(8),
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
// would let one entry silently strip a whole namespace off every owner's overrides.
privilegedEnvKeys: z.array(envName).max(8),
// would let one entry silently strip a whole namespace off every owner's overrides. The
// bound is generous because a longer list only takes more away from non-granted owners:
// copilot's COPILOT_ prefix covers dozens of variables that run code or redirect its token.
privilegedEnvKeys: z.array(envName).max(48),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
+267 -4
View File
@@ -703,6 +703,8 @@ const CODEX: CliEntry = {
// (its notes field open) ` tab or esc to clear notes | enter to submit answer`
// workspace trust, at startup `› 1. Trust and continue`
// ` enter continue · esc quit`
// (the same prompt reached after the ` 2. Back to Agent Command Center`
// update prompt, 0.162.0, 2026-10-16) ` enter continue · esc back`
// The pattern needs both: a `› N.` option row, then the key-hint row, which must END
// the window. Below about 60 columns that hint wraps onto rows of its own
// (` Press enter to confirm or esc to` / ` cancel`, or `tab to add notes` /
@@ -725,7 +727,7 @@ const CODEX: CliEntry = {
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|answer|all)$`,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|back|answer|all)$`,
blockedLines: 20,
},
// The footer under the composer, measured on a live 0.147.0 pane:
@@ -994,6 +996,30 @@ const ANTIGRAVITY: CliEntry = {
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live `agy` 1.3.3 pane (capture-pane every second through a prose turn
// and a shell-call turn, plus the raw PTY stream, 2026-10-11). A running turn animates
// `⣯ Generating...` (the label also reads `Working...` and `Loading...`) one row above
// the composer, and the footer's left end reads `esc to cancel`; at rest the footer
// reads `? for shortcuts` and nothing else on screen matches. A shell call replaces the
// composer with a `Run this command?` list, the spinner stops, and the pane goes
// silent. Nothing arms the idle check while the list is up (the footer hint is drawn only
// at rest), so a pending approval reads as working until it is answered. That differs from
// gemini, whose `▀` bar comes back on every spinner repaint.
// ⚠️ The frames are the 8-dot braille set (`⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽`), none of which is in
// SPINNER_PATTERN's Claude set, and this entry used to declare nothing, so no frame
// ever marked an antigravity tab working and it never showed the activity spinner.
// The composer's `>` is no arming mark: the submitted prompt is echoed as `> <text>`
// too. The footer hint is drawn only at rest, and the turn's last repaint carries it.
// The schema caps a glyph at 8 characters, so this is the head of `? for shortcuts`. It also
// keeps the submit verifier inert (no row starts with it and holds prompt text).
// The working line is anchored to the start of a row, like gemini's braille branch: the
// idle probe tests it against the whole pane, and an answer that QUOTES the spinner row
// (an agent explaining this entry) would otherwise hold the tab busy while it is on screen.
// Every `agy` capture draws the spinner at column 0.
workDetect: {
promptGlyph: '? for sh',
workingLine: String.raw`(?:^|\n)[⠀-⣿] [A-Z][A-Za-z ]{2,30}(?:\.\.\.|…)`,
},
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
@@ -1333,8 +1359,14 @@ const DEEPSEEK: CliEntry = {
launcherTargetParam: 'profile',
install: {
command: {
linux: 'npm install -g @deepseek-ai/dsh',
darwin: 'npm install -g @deepseek-ai/dsh',
// pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the
// terminal profile this CLI needs before it can run cannot be installed without it (#352).
// ⚠️ Only when none is on PATH. `corepack enable` puts a `pnpm` shim in node's own bin
// directory, which is npm's global bin for nvm and Homebrew, and `npm install -g pnpm`
// refuses to overwrite it (EEXIST). Asked for in the same command as dsh, that failure
// took dsh down with it, on exactly the machines that already had a working pnpm.
linux: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
darwin: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)',
},
npmPackage: '@deepseek-ai/dsh',
docsUrl: 'https://github.com/deepseek-ai/deepseek-harness',
@@ -1644,5 +1676,236 @@ const OMP: CliEntry = {
},
};
// GitHub Copilot CLI (`copilot`, github/copilot-cli, npm @github/copilot). Measured on a live
// 1.0.94 pane inside tmux (capture-pane through real turns, a shell tool call and /model,
// 2026-10-09). It is an ALTERNATE-SCREEN TUI with mouse tracking on (tmux `alternate_on=1`,
// `mouse_any_flag=1`, `history_size=0`), so like opencode its mouse DECSETs are stripped
// (`altScreen: 'strip-mux-and-mouse'` below): a drag selects locally and the wheel is forwarded.
// Keystrokes sent with `tmux send-keys` reach its composer and a shell tool call completes
// inside tmux, which github/copilot-cli#4180 (a driven PTY ignoring input) and #4223 (a shell
// command never reported done under tmux) say they do not on 1.0.70 to 1.0.74: neither
// reproduces on 1.0.94, and the register of `version` below is what would show a regression.
const COPILOT: CliEntry = {
id: 'copilot' as CliEntry['id'],
label: 'GitHub Copilot',
shortBadge: 'CP',
accent: '#a371f7',
enabled: true,
stock: true,
order: 100,
kind: 'agent',
discovery: {
binaries: ['copilot'],
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin],
// `copilot --version` prints `GitHub Copilot CLI 1.0.94.`; anchoring on the product name
// keeps an unrelated `copilot` binary on PATH from passing the probe.
version: { arg: '--version', regex: 'GitHub Copilot CLI (\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
install: {
command: { linux: 'npm install -g @github/copilot', darwin: 'npm install -g @github/copilot' },
npmPackage: '@github/copilot',
docsUrl: 'https://github.com/github/copilot-cli',
},
},
// `--yolo` (allow every tool, path and URL) is opt-in through `allowAll`, which the Run button
// sends like the other agent CLIs' bypass switches; an absent config spawns a bare `copilot`
// in its own Manual Approval mode. `--model` and `--resume=<id>` are Copilot's own flags
// (`copilot --help`, 1.0.94). `--resume` takes an id, id prefix or session NAME and its value
// is optional, so the token must be a single plain word that cannot start with `-`: a value
// like `--yolo` would otherwise be parsed as its own flag and get around the `allowAll` clamp.
launch: {
params: {
allowAll: { type: 'bool' },
// `model-plain`, not `model`: the leading alphanumeric keeps a flag-shaped value out.
model: { type: 'token', pattern: 'model-plain' },
// `path-segment`, not `id-dotted`: the leading alphanumeric keeps a flag-shaped value out.
resumeId: { type: 'token', pattern: 'path-segment' },
continueSession: { type: 'bool' },
// The tab's name, so the CLI's own session list reads the same as the tab.
sessionName: { type: 'engine', source: 'sessionName' },
},
variants: [
{
id: 'default',
args: [
{ lit: 'copilot' },
{ flag: '--yolo', when: { param: 'allowAll', is: true } },
// `--name` is refused next to `--resume` and `--continue` (copilot 1.0.95: "cannot be used
// with"), and a resumed session keeps the name it was created with.
{
flag: '--name',
valueFrom: 'sessionName',
quote: 'double',
when: {
allOf: [
{ param: 'sessionName', state: 'set' },
{ param: 'resumeId', state: 'unset' },
{ not: { param: 'continueSession', is: true } },
],
},
},
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
{
lit: '--continue',
when: {
allOf: [
{ param: 'continueSession', is: true },
{ param: 'resumeId', state: 'unset' },
],
},
},
],
},
],
legacyConfigAliases: { resumeId: 'resumeSessionId' },
legacyConfigField: 'copilotConfig',
resumeAppend: { style: 'flag', flag: '--resume' },
},
env: {
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['NO_COLOR'],
// Sign-in is the CLI's own (`copilot login`, kept under ~/.copilot). A headless host sets
// COPILOT_GITHUB_TOKEN, which the COPILOT_ prefix already admits. GH_TOKEN / GITHUB_TOKEN are
// deliberately NOT allowlisted: the env allowlist is one global list with no mode context, so
// admitting them would make them settable on every session (see allowedEnvPrefixes()).
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: ['COPILOT_'],
allowedKeys: [],
},
capabilities: {
...agentDefaults(),
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
// selection and clicks still reach the CLI through the browser's hand-encoded tap
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
altScreen: 'strip-mux-and-mouse',
// Forwards the wheel as SGR reports while the CLI is tracking the mouse, at any version
// (`_shouldForwardWheelToApp` in terminal-ui.js has no version check for copilot).
wheelForward: { mode: 'always' },
// Pages its own transcript on PageUp/PageDown (1.0.95, measured in tmux: three PageUp moved
// lines 109-140 to 010-042 and PageDown returned). This is the fallback when the
// wheel-forward flag is stale after a server restart, so the wheel and a swipe never go dead.
transcriptPageKeys: true,
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// What `codeman agent spawn` waits for. The composer's footer hint, NOT the `❯` glyph: the
// folder-trust dialog draws `❯ 1. Yes` for its selected row, so the glyph would call a worker
// ready while it is still asking for trust (measured on 1.0.95: the dialog's footer reads
// `↑/↓ to navigate`, the composer's `/ commands · ? help`).
composerReadyMark: '/ commands · ? help',
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
// `◉ Working esc edit prompt` (the dot alternates `◉` and `◎`); at rest it reads
// `← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab`.
// A tool call that is waiting for approval replaces the composer and the footer's
// `Working` goes with it, so it reads as idle (waiting on the user), like gemini.
workDetect: {
promptGlyph: '❯',
workingLine: String.raw`[◉◎] Working\b`,
},
// The model is the right-hand field of the last row, both at rest and mid-turn
// (`... tab next tab Claude Sonnet 5.5`, `◎ Working esc edit prompt GPT-6 Luna`).
// Anchored on the footer's own words so a model-looking string in the transcript cannot match.
modelDetect: {
screenLine: String.raw`(?:tab next tab|\? help|esc edit prompt|github-mcp-server) {3,}([A-Za-z0-9][\w.:/@ -]{1,60}?) *(?:\n|$)`,
screenLines: 2,
},
// `copilot mcp add` writes `~/.copilot/mcp-config.json`; COPILOT_HOME replaces ~/.copilot
// (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
mcpConfig: {
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
},
// Every one of these matches the COPILOT_ allowedPrefix, so a non-granted multi-user owner
// must not be able to set it through envOverrides. Read from the 1.0.98 bundle (copilot reads
// far more COPILOT_* variables than `copilot help environment` lists), by what each hands back:
// - approval: COPILOT_ALLOW_ALL turns every tool on; COPILOT_PLAN_THEN_AUTOPILOT switches a
// plan into autopilot; COPILOT_ASSISTED_APPROVAL; COPILOT_HOME restates the permission config.
// - code at boot or without an approval row: COPILOT_CLI_DIST_DIR runs `<dir>/index.js` in place
// of the CLI, the package caches pick which CLI build runs, COPILOT_EDITOR,
// COPILOT_PROVIDER_API_KEY_COMMAND and COPILOT_AHP_HOST_BIN are run as commands, skills and
// provider configs are loaded from the named paths, and the hook switches widen what hooks run.
// - credential redirects: the Copilot and GitHub API bases receive the CLI's sign-in token, which
// in multi-user mode is the operator's (every user shares the OS account and ~/.copilot), and
// the BYOK provider family aims a Codeman custom endpoint elsewhere.
// The prefix stays because COPILOT_GITHUB_TOKEN and the harmless knobs live in it; a variable a
// later release adds is NOT covered until it is listed here.
privilegedEnvKeys: [
'COPILOT_HOME',
'COPILOT_ALLOW_ALL',
'COPILOT_PLAN_THEN_AUTOPILOT',
'COPILOT_ASSISTED_APPROVAL',
'COPILOT_CLI_DIST_DIR',
'COPILOT_CLI_RESOLVED_DIST_DIR',
'COPILOT_PKG_CACHE_HOME',
'COPILOT_CACHE_HOME',
'COPILOT_EDITOR',
'COPILOT_AHP_HOST_BIN',
'COPILOT_DEBUG_VOICE_ENGINE_SCRIPT',
'COPILOT_SKILLS_DIRS',
'COPILOT_PROVIDERS_CONFIG',
'COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS',
'COPILOT_HOOK_ALLOW_LOCALHOST',
'COPILOT_E2E_TEST_HOOKS',
'COPILOT_API_URL',
'COPILOT_DEBUG_GITHUB_API_URL',
'COPILOT_GH_HOST',
'COPILOT_MC_BASE_URL',
'COPILOT_AHP_URL',
'COPILOT_DEBUG_ENTRA_BROKER_URL',
'COPILOT_CAFE_METRICS_URL',
'COPILOT_PROVIDER_BASE_URL',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_API_KEY_COMMAND',
'COPILOT_PROVIDER_BEARER_TOKEN',
'COPILOT_PROVIDER_HEADERS',
'COPILOT_PROVIDER_TYPE',
'COPILOT_PROVIDER_WIRE_MODEL',
'COPILOT_PROVIDER_MODEL_ID',
'COPILOT_PROVIDER_GHES_HOST',
'COPILOT_PROVIDER_GHES_TOKEN',
'COPILOT_MODEL',
],
// Grok/codex-shaped clamp: a bare spawn is already Manual Approval, so the multi-user clamp
// only forces an EXPLICITLY-SENT `--yolo` back off; nothing is materialized when absent.
privilegedParams: [{ param: 'allowAll', clampTo: false }],
// BYOK (`copilot help environment`, 1.0.95): COPILOT_PROVIDER_BASE_URL switches the CLI to a
// 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
// 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: {
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
},
},
overlays: {
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.copilot` row,
// and the sign-in token sits in plain text in config.json on a host without a keyring, so it
// is not copied into a container by default. Sign in inside the container instead.
},
};
/** The full stock catalog, in the order the run menu shows by default. */
export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK, OMP];
export const STOCK_CLIS: CliEntry[] = [
CLAUDE,
SHELL,
OPENCODE,
CODEX,
GEMINI,
ANTIGRAVITY,
PI,
GROK,
DEEPSEEK,
OMP,
COPILOT,
];
+11 -3
View File
@@ -525,7 +525,7 @@ 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 (a full-screen TUI that enables tracking itself); `strip-mux-only` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
@@ -539,8 +539,16 @@ export interface CliCapabilities {
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
predictProfile?: string;
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback;
* 'version-gated' forwards from `minVersion` on; 'always' forwards at any version. Either
* forwarding mode applies only while the CLI has mouse tracking on (`cliMouseTracking`).
*
* ⚠️ Declared only: nothing reads this field yet (see DECLARED-FOR-LATER below). The browser's
* gate is `_shouldForwardWheelToApp` in terminal-ui.js, which keeps its own hand-written
* per-CLI rules, so changing a value here changes nothing until that gate is changed too.
*/
wheelForward: { mode: 'never' | 'version-gated' | 'always'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
+953
View File
@@ -0,0 +1,953 @@
/**
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, so several agents working
* on one repository each get their own working tree instead of sharing the case folder (sessions
* in one case share its folder, and two agents editing, building and committing in one checkout
* step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one
* through the existing create paths (`POST /api/quick-start` with `worktree`, or
* `POST /api/sessions` with the worktree as `workingDir`).
*
* WHERE. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
* branch `worktree-<name>`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and
* Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts).
* Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a
* worktree made here is one `claude --worktree <name>` would also find. The main checkout must not
* show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL
* `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not
* already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its
* hooks there for every claude session, and an untracked copy in a worktree makes
* `git worktree remove` refuse forever (Claude Code itself ignores that file through the global
* excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's
* agent skill (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) is excluded for
* the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`).
*
* Split like the other git modules: a PURE half (name and branch validation, `worktree list`
* parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that
* spawns git through an injectable runner.
*
* Rules the code keeps and the tests pin:
* - git is spawned with an argv array, never a shell, with `--` before every path operand, the
* non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole
* process group, and capped output. Read-only calls add `--no-optional-locks`.
* - Names and branches are validated BEFORE they reach git (charset, length, then
* `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands
* are refused up front, since `--branch` would expand them).
* - Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed. Every component of
* that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the
* worktree itself, is refused) and its real path must be exactly the expected one, so a
* repository cannot redirect a create or a remove outside itself. What is left is a same-user
* race (an agent swapping a directory between the check and git), which is accepted: that
* agent already runs code as this user.
* - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or
* untracked files, and that refusal is the answer), refused while a live session runs inside
* the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged
* branch survives).
* - git never runs for a case at or inside a Docker case workspace (or whose common git dir is
* there): a container could plant a checkout filter or a hook, and `git worktree add` runs the
* repository's post-checkout hook and filters on the HOST. On a local case that is acceptable,
* since the user's agent runs code there anyway. A managed worktree that is, sits inside, or
* holds a Docker case workspace is never removed either: `git worktree remove` runs a status
* inside the tree, then deletes it under the container.
* - A repository that merely encloses the case and is the home folder or above (a dotfiles repo
* in `$HOME`) is not the case's repository, and a case folder below a repository's top level
* is refused: the worktrees would not be where the case is.
*
* @module git-worktrees
*/
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
// ─── Limits ──────────────────────────────────────────────────────────────────
/** Claude Code's own limit for a `--worktree` name (2.1.296). */
export const MAX_WORKTREE_NAME_LENGTH = 64;
/** Longest accepted branch name. git allows more; 200 covers every real one. */
export const MAX_WORKTREE_BRANCH_LENGTH = 200;
/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */
export const WORKTREE_READ_TIMEOUT_MS = 15_000;
/**
* `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and
* smudge filters (Git LFS downloads objects there), so it gets the longest budget.
*/
export const WORKTREE_ADD_TIMEOUT_MS = 180_000;
/** `git worktree remove` runs a status of the tree first, then deletes it. */
export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000;
/** Captured stdout. `worktree list` is one short block per worktree. */
const MAX_STDOUT_BYTES = 1_000_000;
/** Captured stderr tail. */
const MAX_STDERR_BYTES = 8_192;
/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */
export const MAX_WORKTREES_LISTED = 200;
/** Path segments of the managed worktrees folder, relative to the repository root. */
export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const;
/** The `info/exclude` line that hides the managed worktrees from the main checkout. */
export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/';
/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */
export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json';
/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */
const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
/**
* The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace
* (`<skillsDir>/codeman/`, written into the folder a session runs in while `agentSkillEnabled`
* is on), or null for a folder that is not a plain relative path.
*/
export function agentSkillExcludeLine(skillsDir: string): string | null {
if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..')) return null;
return `/${skillsDir}/codeman/`;
}
// ─── Pure: names ─────────────────────────────────────────────────────────────
const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
/**
* Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits,
* `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the
* session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's
* own ref rules, since the default branch is derived from the name).
*/
export function worktreeNameError(name: unknown): string | null {
if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.';
if (name.length > MAX_WORKTREE_NAME_LENGTH)
return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`;
if (!WORKTREE_NAME_RE.test(name))
return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.';
if (name.includes('..')) return 'A worktree name may not contain "..".';
if (name.endsWith('.') || name.toLowerCase().endsWith('.lock'))
return 'A worktree name may not end with "." or ".lock".';
return null;
}
/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-<name>`. */
export function defaultWorktreeBranch(name: string): string {
return `worktree-${name}`;
}
/**
* Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final
* word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing
* option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all.
*/
export function worktreeBranchError(branch: unknown): string | null {
if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.';
if (branch.length > MAX_WORKTREE_BRANCH_LENGTH)
return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`;
if (!BRANCH_NAME_RE.test(branch))
return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.';
if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".';
if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock'))
return 'A branch name may not end with "/", "." or ".lock".';
if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".';
return null;
}
/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */
export function worktreeBaseError(base: unknown): string | null {
if (typeof base !== 'string' || !isSafeGitRef(base))
return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").';
return null;
}
// ─── Pure: paths ─────────────────────────────────────────────────────────────
/** `<repo>/.claude/worktrees`. */
export function managedWorktreesDir(repoRoot: string): string {
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
}
/** `<repo>/.claude/worktrees/<name>`. The caller validates `name` first. */
export function managedWorktreePath(repoRoot: string, name: string): string {
return join(managedWorktreesDir(repoRoot), name);
}
/**
* The managed worktree name of `path`, or null when it is not exactly
* `<repoRoot>/.claude/worktrees/<valid-name>`. Lexical: both sides must already be real paths.
*/
export function managedWorktreeName(repoRoot: string, path: string): string | null {
const dir = managedWorktreesDir(repoRoot) + sep;
const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path;
if (!clean.startsWith(dir)) return null;
const name = clean.slice(dir.length);
if (!name || name.includes(sep)) return null;
return worktreeNameError(name) === null ? name : null;
}
/** True when `child` is `root` or inside it (lexical, on resolved paths). */
export function isPathWithin(child: string, root: string): boolean {
const c = resolve(child);
const r = resolve(root);
return c === r || c.startsWith(r === sep ? r : r + sep);
}
/**
* `path` as the repository spells it: a path under `alias` (the case folder as its case resolves
* it, which can run through a symlink) moved under `root` (the repository's real top level), else
* `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the
* `workingDir` they were created with, while the worktree paths git lists are real, so without
* this a session spelled through the case's link counted for no worktree and did not block its
* removal.
*/
export function respellUnder(path: string, alias: string, root: string): string {
const p = resolve(path);
const a = resolve(alias);
if (a === resolve(root) || !isPathWithin(p, a)) return p;
return join(root, p.slice(a.length));
}
// ─── Pure: `git worktree list --porcelain` ───────────────────────────────────
export interface ParsedWorktree {
path: string;
/** Commit checked out (absent for a bare entry). */
head: string | null;
/** Short branch name (`refs/heads/` stripped), null when detached or bare. */
branch: string | null;
detached: boolean;
bare: boolean;
locked: boolean;
lockReason?: string;
prunable: boolean;
prunableReason?: string;
}
/**
* Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when
* they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are
* raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged.
*/
export function unquoteGitPath(text: string): string {
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text;
const body = text.slice(1, -1);
const bytes: number[] = [];
const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\') {
for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b);
continue;
}
const next = body[i + 1];
if (next !== undefined && /[0-7]/.test(next)) {
const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0];
bytes.push(parseInt(oct, 8) & 0xff);
i += oct.length;
} else if (next !== undefined && next in simple) {
bytes.push(simple[next]);
i += 1;
} else {
bytes.push(92);
}
}
return Buffer.from(bytes).toString('utf-8');
}
/**
* Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu
* 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths
* are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII
* repository paths readable.
*/
export function parseWorktreeList(text: string): ParsedWorktree[] {
const out: ParsedWorktree[] = [];
let cur: ParsedWorktree | null = null;
const flush = () => {
if (cur) out.push(cur);
cur = null;
};
for (const raw of text.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) {
flush();
continue;
}
const space = line.indexOf(' ');
const key = space === -1 ? line : line.slice(0, space);
const value = space === -1 ? '' : line.slice(space + 1);
if (key === 'worktree') {
flush();
cur = {
path: unquoteGitPath(value),
head: null,
branch: null,
detached: false,
bare: false,
locked: false,
prunable: false,
};
continue;
}
if (!cur) continue;
const entry: ParsedWorktree = cur;
if (key === 'HEAD') entry.head = value || null;
else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null;
else if (key === 'detached') entry.detached = true;
else if (key === 'bare') entry.bare = true;
else if (key === 'locked') {
entry.locked = true;
if (value) entry.lockReason = value;
} else if (key === 'prunable') {
entry.prunable = true;
if (value) entry.prunableReason = value;
}
}
flush();
return out;
}
// ─── Pure: exclude file ──────────────────────────────────────────────────────
/**
* What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it:
* the text ('' when nothing is missing) and the lines it adds. A file that does not end in a
* newline gets one first, so an appended pattern never fuses with the user's last line.
*/
export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } {
const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
const added = lines.filter((l) => !present.has(l));
if (added.length === 0) return { text: '', added };
const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
return {
text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`,
added,
};
}
// ─── Pure: git's answers ─────────────────────────────────────────────────────
/**
* The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines
* dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only.
*/
export function gitRefusalText(stderr: string): string {
const clean = sanitizeGitOutput(stderr, 2000);
const lines = clean
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !/^hint:/i.test(l));
const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l));
const text = (diagnostic.length ? diagnostic : lines).join('\n');
return text.length > 1000 ? `${text.slice(0, 1000)}…` : text;
}
// ─── IO: git runner ──────────────────────────────────────────────────────────
export interface WorktreeGitResult {
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
spawnError?: string;
}
/** Runs `git <args>` in `cwd`; never throws. Injected so tests can refuse to run git at all. */
export type WorktreeGitRunner = (
cwd: string,
args: string[],
opts?: { timeoutMs?: number }
) => Promise<WorktreeGitResult>;
/**
* Global options for a call that only reads: no optional index refresh, no filesystem monitor,
* no path quoting of non-ASCII names. Must precede the subcommand.
*/
const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false'];
/**
* The production runner: argv only (no shell), the non-interactive environment, captured output
* caps, and a timeout that signals the whole process group (`detached: true`), because
* `git worktree add` fans out into checkout children, hooks and filters that a signal to the
* parent alone would leave running.
*/
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
new Promise<WorktreeGitResult>((resolveRun) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn('git', args, {
cwd,
env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
} catch (err) {
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) });
return;
}
let stdout = '';
let stdoutBytes = 0;
let stderr = '';
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
}, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8');
});
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES);
});
const finish = (result: WorktreeGitResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolveRun(result);
};
child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) }));
child.on('close', (code) => finish({ code, stdout, stderr, timedOut }));
});
// ─── IO: results ─────────────────────────────────────────────────────────────
/** Failure codes, named after the API error codes the routes map them to. */
export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED';
export interface WorktreeFailure {
ok: false;
code: WorktreeFailureCode;
message: string;
/** git's own words, when git refused (sanitized; render as text). */
gitMessage?: string;
}
const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({
ok: false,
code,
message,
...(gitMessage ? { gitMessage } : {}),
});
function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure {
if (run.spawnError && /enoent/i.test(run.spawnError))
return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).');
if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`);
const text = gitRefusalText(run.stderr);
return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text);
}
// ─── IO: the case's repository ───────────────────────────────────────────────
export interface CaseRepository {
/** The repository's top level, which is the case folder (real path). */
repoRoot: string;
/** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */
commonDir: string;
}
export interface WorktreeOptions {
git?: WorktreeGitRunner;
/** Host paths of every Docker case workspace: git never runs at or inside one. */
dockerWorkspaces?: string[];
/** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */
home?: string;
/**
* Every workspace skills folder a session can get Codeman's agent skill in (the registry's
* `capabilities.agentSkillDir` values, passed in as data by the caller). A create excludes
* `<dir>/codeman/` for each, like the hooks file: a session in a worktree gets an untracked
* copy there, and `git worktree remove` would refuse that worktree forever.
*/
agentSkillDirs?: readonly string[];
}
const realOr = async (p: string): Promise<string> => {
try {
return await fs.realpath(p);
} catch {
return resolve(p);
}
};
const DOCKER_REFUSAL =
'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.';
const DOCKER_WORKTREE_REFUSAL =
'This worktree is, or holds, a Docker case workspace: git would run inside a folder a container can write, and removing it would delete that workspace. Remove the Docker case first.';
/**
* The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs
* git when the folder (or the repository's common git dir) is at or inside a Docker case workspace.
*/
export async function resolveCaseRepository(
casePath: string,
opts: WorktreeOptions = {}
): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (run.code !== 0 || run.spawnError || run.timedOut) {
if (/must be run in a work tree/i.test(run.stderr)) {
return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.');
}
if (/not a git repository/i.test(run.stderr)) {
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
}
return gitFailure(run, 'reading the repository');
}
const [top, common] = run.stdout.split('\n').map((l) => l.trim());
if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.');
const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]);
const commonDir = await realOr(resolve(casePath, common));
if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL);
if (repoRoot !== caseReal) {
if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) {
return fail('INVALID_INPUT', 'This case folder is not a git repository.');
}
return fail(
'INVALID_INPUT',
`Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.`
);
}
return { ok: true, repo: { repoRoot, commonDir } };
}
// ─── IO: listing ─────────────────────────────────────────────────────────────
export interface CaseWorktree extends ParsedWorktree {
/** The repository's main checkout (git lists it first). */
main: boolean;
/** The case folder itself (the main checkout, or a linked worktree linked as the case). */
isCase: boolean;
/** Under `<repo>/.claude/worktrees/<valid-name>`: Codeman may start sessions in it and remove it. */
managed: boolean;
/** The managed name, when `managed`. */
name?: string;
}
export async function listCaseWorktrees(
repo: CaseRepository,
opts: WorktreeOptions = {}
): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees');
const parsed = parseWorktreeList(run.stdout);
const truncated = parsed.length > MAX_WORKTREES_LISTED;
const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => {
const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path);
return {
...wt,
main: index === 0,
isCase: wt.path === repo.repoRoot,
managed: name !== null,
...(name ? { name } : {}),
};
});
return { ok: true, worktrees, truncated };
}
// ─── IO: path guards ─────────────────────────────────────────────────────────
type DirState = 'dir' | 'absent' | 'symlink' | 'other';
async function lstatState(path: string): Promise<DirState> {
try {
const st = await fs.lstat(path);
if (st.isSymbolicLink()) return 'symlink';
return st.isDirectory() ? 'dir' : 'other';
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return 'absent';
throw err;
}
}
/**
* Make sure `<repo>/.claude` and `<repo>/.claude/worktrees` are real directories (created when
* absent, refused when a symlink or a file) whose real path is exactly the lexical one.
*/
async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> {
let cur = repoRoot;
for (const segment of WORKTREES_DIR_SEGMENTS) {
cur = join(cur, segment);
const state = await lstatState(cur);
if (state === 'symlink') {
return fail(
'INVALID_INPUT',
`${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.`
);
}
if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`);
if (state === 'absent') {
try {
await fs.mkdir(cur);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`);
}
if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`);
}
}
}
if ((await realOr(cur)) !== cur) {
return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`);
}
return { ok: true, dir: cur };
}
/**
* The managed worktree `name` of `repo` as it is on disk now: every component a real directory
* (no symlinks), the real path the expected one, and git listing it as a worktree of this
* repository. Used by removal and by starting a session in it.
*
* `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is
* GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the
* managed path: no component on the way may be a symlink or a file, and `missing` says so.
* Without it such a worktree was a dead end: it could not be removed, and its name could not be
* used again (the branch exists, then git refuses "a missing but already registered worktree").
*/
export async function resolveManagedWorktree(
repo: CaseRepository,
name: string,
opts: WorktreeOptions & { allowMissing?: boolean } = {}
): Promise<{ ok: true; path: string; worktree: CaseWorktree; missing: boolean } | WorktreeFailure> {
const nameError = worktreeNameError(name);
if (nameError) return fail('INVALID_INPUT', nameError);
const target = managedWorktreePath(repo.repoRoot, name);
let cur = repo.repoRoot;
let missing = false;
for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) {
cur = join(cur, segment);
const state = await lstatState(cur);
if (state === 'absent') {
if (!opts.allowMissing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
// Nothing below an absent folder exists, so nothing below it can redirect anything.
missing = true;
break;
}
if (state === 'symlink') {
return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`);
}
if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`);
}
if (!missing && (await realOr(target)) !== target) {
return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`);
}
const listed = await listCaseWorktrees(repo, opts);
if (!listed.ok) return listed;
for (const wt of listed.worktrees) {
if (wt.main) continue;
if (missing) {
// Lexical only: the folder is gone, so there is no real path to compare. git never calls a
// LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's
// lock check then refuses it with git's lock reason.
if (wt.path === target && (wt.prunable || wt.locked)) {
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true };
}
continue;
}
if (wt.path === target || (await realOr(wt.path)) === target) {
return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false };
}
}
if (missing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`);
return fail(
'NOT_FOUND',
`${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).`
);
}
// ─── IO: excludes ────────────────────────────────────────────────────────────
/**
* Append each of `lines` that git does not already ignore to `<commonDir>/info/exclude`. Returns
* the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never
* written through).
*/
async function ensureExcluded(
repo: CaseRepository,
probes: Array<{ line: string; probe: string }>,
git: WorktreeGitRunner
): Promise<{ added: string[]; warning?: string }> {
const needed: string[] = [];
for (const { line, probe } of probes) {
const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
// 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an
// extra exclude line is harmless and a missing one leaves the worktrees untracked.
if (run.code !== 0) needed.push(line);
}
if (needed.length === 0) return { added: [] };
const infoDir = join(repo.commonDir, 'info');
const excludePath = join(infoDir, 'exclude');
try {
const infoState = await lstatState(infoDir);
if (infoState === 'absent') await fs.mkdir(infoDir);
else if (infoState !== 'dir')
return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` };
const fileState = await lstatState(excludePath);
if (fileState === 'symlink' || fileState === 'dir') {
return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` };
}
const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8');
const { text, added } = excludeAppendText(existing, needed);
if (text) await fs.appendFile(excludePath, text, { mode: 0o644 });
return { added };
} catch (err) {
return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` };
}
}
// ─── IO: one operation per repository at a time ──────────────────────────────
const repoLocks = new Map<string, Promise<unknown>>();
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
const prev = repoLocks.get(repoRoot) ?? Promise.resolve();
const next = prev.then(fn, fn);
const tail = next.catch(() => undefined);
repoLocks.set(repoRoot, tail);
try {
return await next;
} finally {
if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot);
}
}
// ─── IO: create ──────────────────────────────────────────────────────────────
export interface CreateWorktreeRequest {
name: string;
/** New branch; default `worktree-<name>`. */
branch?: string;
/** Start point; default `HEAD` (the case's current commit). */
base?: string;
}
export interface CreatedWorktree {
name: string;
path: string;
branch: string;
base: string;
/** The commit the new branch starts at. */
head: string;
/** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */
excludeAdded: string[];
warnings: string[];
}
export async function createCaseWorktree(
repo: CaseRepository,
req: CreateWorktreeRequest,
opts: WorktreeOptions = {}
): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
const nameError = worktreeNameError(req.name);
if (nameError) return fail('INVALID_INPUT', nameError);
const branch = req.branch || defaultWorktreeBranch(req.name);
const branchError = worktreeBranchError(branch);
if (branchError) return fail('INVALID_INPUT', branchError);
const base = req.base || 'HEAD';
const baseError = worktreeBaseError(base);
if (baseError) return fail('INVALID_INPUT', baseError);
return withRepoLock(repo.repoRoot, async () => {
// A name already taken says so in those words, before the branch check would report the
// derived branch instead. Rechecked after the folders are prepared, below.
try {
if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') {
return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`);
}
} catch {
/* unreadable: the check after the folders are prepared reports it */
}
// git's own word on the branch name. The output must be the input: `--branch` expands
// shorthands, and anything it rewrote is not the name the user typed.
const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], {
timeoutMs: WORKTREE_READ_TIMEOUT_MS,
});
if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) {
return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr));
}
const existingBranch = await git(
repo.repoRoot,
[...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
);
if (existingBranch.code === 0) {
return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`);
}
const resolved = await git(
repo.repoRoot,
// No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a
// leading `-`, so it cannot be read as an option.
[...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`],
{ timeoutMs: WORKTREE_READ_TIMEOUT_MS }
);
const head = resolved.stdout.trim();
if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) {
return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`);
}
const prepared = await prepareWorktreesDir(repo.repoRoot);
if (!prepared.ok) return prepared;
const target = join(prepared.dir, req.name);
try {
if ((await lstatState(target)) !== 'absent') {
return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`);
}
} catch (err) {
return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`);
}
const skillProbes: Array<{ line: string; probe: string }> = [];
for (const dir of new Set(opts.agentSkillDirs ?? [])) {
const line = agentSkillExcludeLine(dir);
if (line) skillProbes.push({ line, probe: `${dir}/codeman` });
}
const excluded = await ensureExcluded(
repo,
[
{ line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` },
{ line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' },
...skillProbes,
],
git
);
// The resolved commit, not the ref text, after `--`: nothing here can be read as an option,
// and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main.
const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], {
timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
});
if (add.code !== 0 || add.spawnError || add.timedOut) {
const text = gitRefusalText(add.stderr);
if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text);
return gitFailure(add, 'creating the worktree');
}
return {
ok: true,
worktree: {
name: req.name,
path: target,
branch,
base,
head,
excludeAdded: excluded.added,
warnings: excluded.warning ? [excluded.warning] : [],
},
};
});
}
// ─── IO: remove ──────────────────────────────────────────────────────────────
export interface RemoveWorktreeRequest {
name: string;
/** Also `git branch -d` the worktree's branch (never `-D`). */
deleteBranch?: boolean;
/** True when a live session runs at or inside `path`: the removal is refused. */
isInUse: (path: string) => boolean | Promise<boolean>;
}
export interface RemovedWorktree {
name: string;
path: string;
branch: string | null;
branchDeleted: boolean;
/** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */
branchError?: string;
}
export async function removeCaseWorktree(
repo: CaseRepository,
req: RemoveWorktreeRequest,
opts: WorktreeOptions = {}
): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> {
const git = opts.git ?? runWorktreeGit;
return withRepoLock(repo.repoRoot, async () => {
// A worktree whose folder is gone is removable too: `git worktree remove` then only drops
// git's record of it (measured on git 2.43: exit 0, nothing on disk touched).
const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true });
if (!found.ok) return found;
// The case passed the Docker rule, but the worktree folder itself can be a Docker case's
// workspace (a Docker case linked to it), or hold one. `git worktree remove` runs a status
// INSIDE the tree first (container-written files, checkout filters on this machine), then
// deletes the whole tree, ignored files included, under a container that still mounts it.
// A folder that is gone has neither problem: git only drops its record.
if (!found.missing) {
const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr));
if (dockerRoots.some((root) => isPathWithin(found.path, root) || isPathWithin(root, found.path))) {
return fail('INVALID_INPUT', DOCKER_WORKTREE_REFUSAL);
}
}
if (await req.isInUse(found.path)) {
return fail(
'CONFLICT',
`A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.`
);
}
if (found.worktree.locked) {
return fail(
'CONFLICT',
`Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.`
);
}
const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], {
timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS,
});
if (removed.code !== 0 || removed.spawnError || removed.timedOut) {
if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree');
const text = gitRefusalText(removed.stderr);
// git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is
// the answer, in git's words.
return fail(
'CONFLICT',
text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.',
text
);
}
const branch = found.worktree.branch;
let branchDeleted = false;
let branchError: string | undefined;
if (req.deleteBranch && branch) {
const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS });
if (del.code === 0) branchDeleted = true;
else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.';
}
return {
ok: true,
removed: {
name: req.name,
path: found.path,
branch,
branchDeleted,
...(branchError ? { branchError } : {}),
},
};
});
}
+13
View File
@@ -1131,6 +1131,19 @@ export async function readPlanUsageTelemetryEnabled(): Promise<boolean> {
return settings.showPlanUsageLimits !== false;
}
/**
* Whether every Claude session is launched with `--name` set to its tab name
* (the `pinCliSessionName` setting). Read FRESH from settings.json at every
* claude create and respawn, like {@link readPlanUsageTelemetryEnabled}, so a
* toggle applies from the next spawn with no server restart. An ABSENT key
* means OFF: only a name the user chose is pinned, and Claude titles the
* placeholder- and auto-named conversations itself (see `Session.cliPinnedName`).
*/
export async function readPinCliSessionNameEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.pinCliSessionName === true;
}
/**
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
+6 -3
View File
@@ -4,9 +4,12 @@
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
* config file already exists (an absent tool is reported `absent`, never created), and sort after
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
* Copilot CLI is the first. It is also a run mode that declares its own `mcpConfig`, so while that
* entry is enabled this row is skipped (`mcpSyncOnlyTargets` drops ids the registry already
* supplies); it applies when the run mode is disabled but the tool is still on the machine. They are
* plain data here, take part only when installed or when their config file already exists (an
* absent tool is reported `absent`, never created), and sort after the registry CLIs, so when two
* definitions of a name differ the registry CLI's is the one copied.
*
* @module mcp-sync-targets
*/
+7 -2
View File
@@ -20,6 +20,7 @@ import type {
AntigravityConfig,
PiConfig,
GrokConfig,
CopilotConfig,
DeepSeekConfig,
OmpConfig,
SessionRemote,
@@ -97,7 +98,8 @@ export interface CreateSessionOptions {
* Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only).
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
* entry and the terminal title, and a pinned title stops Claude generating its own,
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
* so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName`
* setting pins every tab name (see `Session.cliPinnedName`).
*/
cliName?: string;
niceConfig?: NiceConfig;
@@ -110,6 +112,7 @@ export interface CreateSessionOptions {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
@@ -141,7 +144,8 @@ export interface RespawnPaneOptions {
* Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only).
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
* entry and the terminal title, and a pinned title stops Claude generating its own,
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
* so by default only a user-chosen name belongs here; the opt-in `pinCliSessionName`
* setting pins every tab name (see `Session.cliPinnedName`).
*/
cliName?: string;
niceConfig?: NiceConfig;
@@ -154,6 +158,7 @@ export interface RespawnPaneOptions {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation when respawning */
+23
View File
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
if (value !== undefined) item[key] = value;
}
/** `<repo>/.claude/worktrees/<name>`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */
const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* The worktree a working directory is, by its layout alone: `{ repo, name }` for
* `<repo>/.claude/worktrees/<name>` (the folder itself, not a subfolder of it), else null.
*/
export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null {
const m = WORKTREE_PATH.exec(workingDir);
return m ? { repo: m[1], name: m[2] } : null;
}
/**
* Merge all source views into one list, applying precedence
* (history → lifecycle → persisted → live) and the meaningfulness floor.
@@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (byDir) item.lastPrompt = byDir.prompt;
}
}
// A live or persisted session started in a worktree Codeman created (or Claude Code's own
// `--worktree`) has no transcript record naming it until claude writes one, and a codex or
// shell session never gets one, so its row read like any other session of the repo. The
// folder layout says which worktree it is; a transcript record, when there is one, still wins.
if (!item.worktreeName && item.workingDir) {
const wt = worktreeFromPath(item.workingDir);
if (wt) {
item.worktreeName = wt.name;
item.worktreeRepo ??= wt.repo;
}
}
}
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
+2
View File
@@ -33,6 +33,7 @@ import type {
EffortLevel,
GeminiConfig,
GrokConfig,
CopilotConfig,
OmpConfig,
OpenCodeConfig,
PiConfig,
@@ -50,6 +51,7 @@ export interface SpawnBridgeOptions {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
+1 -1
View File
@@ -4,7 +4,7 @@
*
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* text + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
+87 -16
View File
@@ -56,6 +56,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type CopilotConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
@@ -499,6 +500,8 @@ export class Session extends EventEmitter {
private _name: string;
private _nameSource: SessionNameSource;
/** `pinCliSessionName` as of the last spawn (see {@link _refreshCliNamePin}). */
private _pinEveryCliName = false;
/**
* Reconstructs the composer draft from USER keystrokes so the first real
* prompt can name the tab. Fed only when a write says `fromUser`, and never
@@ -568,6 +571,7 @@ export class Session extends EventEmitter {
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
private _lastPaneProbeTrustDialog = false; // Same capture showed the workspace-trust dialog (see _confirmIdle)
/**
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
*
@@ -747,6 +751,8 @@ export class Session extends EventEmitter {
private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined;
// GitHub Copilot configuration (only for mode === 'copilot')
private _copilotConfig: CopilotConfig | undefined;
// DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined;
@@ -874,6 +880,7 @@ export class Session extends EventEmitter {
piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** OMP configuration (only for mode === 'omp') */
@@ -1036,6 +1043,11 @@ export class Session extends EventEmitter {
this._grokConfig = config.grokConfig;
}
// Apply GitHub Copilot configuration
if (config.copilotConfig) {
this._copilotConfig = config.copilotConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -1805,16 +1817,31 @@ export class Session extends EventEmitter {
}
/**
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude
* title the conversation itself. `--name` is the prompt-box label, the
* `/resume` picker entry and the terminal title all at once, and a pinned
* title stops Claude generating its own, so only a name the user chose is
* worth pinning. Pinning the `w1-myapp` placeholder gave every conversation
* in a case the same `/resume` entry; an auto name is a cut of the first
* prompt, which Claude's own generated title already beats.
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude title the
* conversation itself. `--name` is the prompt-box label, the `/resume` picker entry and the
* terminal title all at once, and a pinned title stops Claude generating its own.
*
* By default only a name the user chose is pinned. Pinning the `w1-myapp` placeholder gave
* every conversation in a case the same `/resume` entry (the tab number restarts once a
* case's tabs are closed), and an auto name is a cut of the first prompt, which Claude's
* own generated title already beats.
*
* With the opt-in `pinCliSessionName` setting every non-blank tab name is pinned, so the
* CLI's labels read the same as the tabs, at the cost of those generated titles. A rename
* in Codeman then holds only until the next turn: a process started with `--name`
* re-appends its in-memory title (see claude-session-title.ts). The setting is re-read
* at each spawn and respawn by {@link _refreshCliNamePin}.
*/
get cliPinnedName(): string | undefined {
return this._nameSource === 'manual' ? this._name : undefined;
if (!this._pinEveryCliName) return this._nameSource === 'manual' ? this._name : undefined;
return this._name.trim() ? this._name : undefined;
}
/** Re-read `pinCliSessionName` so `cliPinnedName` decides per launch, with no server restart. */
private async _refreshCliNamePin(): Promise<void> {
// Dynamic: hooks-config -> route-helpers imports Session, so a static import would be a cycle.
const { readPinCliSessionNameEnabled } = await import('./hooks-config.js');
this._pinEveryCliName = await readPinCliSessionNameEnabled();
}
setAutoClear(enabled: boolean, threshold?: number): void {
@@ -2008,6 +2035,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
@@ -2380,6 +2408,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
deepSeekConfig: this._deepSeekConfig,
// OMP resolution/pinning does NOT happen here. This object is built
// EAGERLY — including on every boot-recovery reattach, before anyone
@@ -2474,6 +2503,7 @@ export class Session extends EventEmitter {
* still inside it and renders no local `--session-id` to collide.
*/
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
await this._refreshCliNamePin();
const options = this._buildRespawnPaneOptions();
if (this._remote || this._docker) return options;
const entry = getCli(this.mode);
@@ -2738,11 +2768,14 @@ export class Session extends EventEmitter {
throw new Error('Session is being closed');
}
await this._refreshCliNamePin();
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup.
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
this._lastPaneProbeTrustDialog = false; // the last pane's verdict, not this one's
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
@@ -2864,6 +2897,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
@@ -3179,13 +3213,24 @@ export class Session extends EventEmitter {
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
private _maybeAcceptTrustDialog(): void {
if (this._trustDialogAccepted) return;
// Stopped: the kill itself produces PTY output, and the buffer tail still holds the
// dialog, so without this a deleted session kept "answering" it for several seconds.
if (this._trustDialogAccepted || this._isStopped) return;
const now = Date.now();
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
return;
}
if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return;
const sinceScan = now - this._lastTrustDialogScanAt;
if (sinceScan < TRUST_DIALOG_RETRY_MS) {
// ⚠️ Defer a throttled read, never drop it. The dialog's own paint is usually the
// last output a launching pane produces, and it tends to land inside the throttle
// of the read the blank screen before it triggered: dropped, nothing re-read the
// screen and the session sat on the dialog for good (2 of 12 fresh launches on
// Claude Code 2.1.294). A read already scheduled covers this one.
if (!this._trustDialogTimer) this._rescanTrustDialogIn(TRUST_DIALOG_RETRY_MS - sinceScan + 100);
return;
}
this._lastTrustDialogScanAt = now;
// Prefer the pane; fall back to the buffer tail on a direct-PTY session,
@@ -3220,14 +3265,17 @@ export class Session extends EventEmitter {
// on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is
// one-shot and self-rearming through this same path, and every exit route
// goes through _clearAllTimers().
// The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at
// exactly the throttle boundary would let the scan return early and break
// the chain with the dialog still on screen.
// The +100ms puts the re-entry OUTSIDE the scan throttle above.
this._rescanTrustDialogIn(TRUST_DIALOG_RETRY_MS + 100);
}
/** Re-run the trust-dialog scan after `delayMs`, replacing any read already scheduled. */
private _rescanTrustDialogIn(delayMs: number): void {
if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = setTimeout(() => {
this._trustDialogTimer = null;
this._maybeAcceptTrustDialog();
}, TRUST_DIALOG_RETRY_MS + 100);
}, delayMs);
}
/**
@@ -3314,6 +3362,18 @@ export class Session extends EventEmitter {
text === null
? null
: !blockedOnScreen && (this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text));
// A capture that failed proves nothing either way, so it keeps the last answer.
if (text !== null) {
const trustDialog = isTrustDialogScreen(text);
if (
trustDialog &&
!this._lastPaneProbeTrustDialog &&
now - this._interactiveStartedAt <= TRUST_DIALOG_WINDOW_MS
) {
console.log(`[Session] Holding idle while the workspace trust dialog is on screen: ${this.id}`);
}
this._lastPaneProbeTrustDialog = trustDialog;
}
this._readWatching(text);
this._readScreenModel(text);
return this._lastPaneProbeWorking;
@@ -3402,6 +3462,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
};
@@ -3673,6 +3734,16 @@ export class Session extends EventEmitter {
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
// ⚠️ The workspace-trust dialog is not a composer, although its `❯` (on "No, exit")
// arms this check and the dialog sits quiet. Calling it idle let an agent that waited
// for idle send its prompt into the dialog, and the prompt's Enter answered *exit*.
// Not working either, so the status stays where it is until the dialog is gone.
// Bounded by the auto-accept's own startup window: later on, a screen that merely
// quotes the dialog (an agent reading session-trust-dialog.ts) must not hold idle off.
if (this._lastPaneProbeTrustDialog && Date.now() - this._interactiveStartedAt <= TRUST_DIALOG_WINDOW_MS) {
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
this._awaitingIdleConfirmation = false;
this.activityTimeout = null;
// Emit idle if either:
@@ -4685,7 +4756,7 @@ export class Session extends EventEmitter {
*
* More reliable than direct PTY write for programmatic input, especially
* with Claude CLI which uses Ink (React for terminals).
* Uses tmux `send-keys -l` to inject text + Enter.
* Uses tmux to inject the text (one bracketed paste) + Enter.
*
* @param data - Input data with optional `\r` for Enter
* @returns true if input was sent, false if no mux session or PTY
@@ -4742,7 +4813,7 @@ export class Session extends EventEmitter {
: this._mux.capturePaneText?.(this._muxSession.muxName),
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
// Only claude, codex, pi, opencode, omp and gemini declare a promptGlyph; the other modes would fall back
// Only claude, codex, pi, opencode, omp, gemini and antigravity declare a promptGlyph; the other modes would fall back
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
// on screen for as long as the command runs, promptStillInComposer() reads that as
+135 -24
View File
@@ -7,13 +7,13 @@
* - **Persistence**: Sessions survive server restarts and disconnects
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
* - **Resource tracking**: Memory, CPU, and child process stats per session
* - **Reliable input**: `send-keys -l` sends literal text in a single command
* - **Reliable input**: a submitted prompt goes in as one bracketed paste, then a separate Enter
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
*
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
*
* Key features:
* - `send-keys 'text' Enter` sends literal text in a single command
* - `paste-buffer -p` + a separate `send-keys Enter` submits a prompt (see sendInput)
* - `list-sessions -F` provides structured queries
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
* - Single server architecture
@@ -53,6 +53,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type CopilotConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
@@ -436,6 +437,84 @@ export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): str
return `${muxName}.${paneTarget}`;
}
let submitBufferSeq = 0;
/** The tmux commands of one bracketed paste, see buildPasteTextCommands(). */
export interface PasteTextCommands {
/** Fills a buffer of its own with the text. */
set: string;
/** Pastes that buffer into the target, bracketed, and deletes it (`-d`). */
paste: string;
/** Deletes the buffer when `paste` failed; `-d` only runs on a successful paste. */
discard: string;
}
/**
* Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;`
* is no argument at all); a final `\;` is its escape for a literal `;`. A `;`
* anywhere else in the argument is literal already. Exported for unit testing.
*/
export function escapeTmuxTrailingSemicolon(text: string): string {
return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
}
/**
* Whether a text+Enter submit types `text` as keys instead of pasting it: true for
* a single character. Claude Code's and Codex's selection dialogs ignore a bracketed
* paste, so a pasted `3` + Enter confirms the highlighted option instead of option 3;
* a typed `3` selects it. One character cannot form the burst Codex reads as a paste,
* so typing it is safe there. Exported for unit testing.
*/
export function submitsAsKeys(text: string): boolean {
return [...text].length === 1;
}
/**
* tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a
* separately sent `Enter` that submits it. Exported for unit testing.
*
* Why a paste and not `send-keys -l`: Codex detects a paste that arrives WITHOUT
* bracketed-paste markers heuristically, from characters arriving in a fast burst,
* and folds an Enter that lands inside that burst window into the paste as a
* newline. `send-keys -l` types exactly such an unmarked burst, so a text+Enter
* pair sat in Codex's composer unsubmitted: always for long text (Codex shows it as
* `[Pasted Content N chars]`, which SubmitVerifier cannot match against the prompt,
* so nothing re-presses Enter), intermittently for short text at the 50 ms gap.
* `paste-buffer -p` wraps the text in the markers (only when the pane asked for
* them, so an app without bracketed paste sees the same bytes as before) and the
* Enter after it is unambiguous.
*
* Each call gets its own named buffer so concurrent sends cannot paste each other's
* text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims
* only automatic ones), hence `discard` for a paste that fails.
*
* A single character is typed instead (submitsAsKeys), so a dialog answer selects.
* A final `;` is escaped (escapeTmuxTrailingSemicolon).
*/
export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands {
const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`;
return {
set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(escapeTmuxTrailingSemicolon(text))}`,
paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`,
discard: `${tmux} delete-buffer -b ${buffer}`,
};
}
/**
* Run a bracketed paste. A failed paste (the pane or the server is gone) deletes
* the buffer before the paste's own error propagates; a failing delete is ignored.
*/
export async function pasteText(run: (cmd: string) => Promise<unknown>, cmds: PasteTextCommands): Promise<void> {
await run(cmds.set);
try {
await run(cmds.paste);
} catch (err) {
await run(cmds.discard).catch(() => undefined);
throw err;
}
}
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
@@ -1003,6 +1082,7 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
@@ -1011,7 +1091,7 @@ export function buildSpawnCommand(options: {
advisorModel?: string;
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string;
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
@@ -2239,6 +2319,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -2325,6 +2406,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -2574,6 +2656,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -2615,6 +2698,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
@@ -3738,10 +3822,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Send input directly to a tmux session using `send-keys`.
* Send input directly to a tmux session.
*
* Uses tmux send-keys for reliable input delivery:
* - `-l` flag sends literal text (no key interpretation)
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
* so Codex cannot fold the Enter into a paste burst; a single character is typed
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
@@ -3776,19 +3862,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// character in the input buffer rather than as form submission. The text goes
// in as a bracketed paste so Codex cannot fold the Enter into it either
// (see buildPasteTextCommands). A single character is typed, because a
// selection dialog ignores a pasted `3` and Enter then confirms the
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else {
await pasteText(
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
@@ -3930,19 +4029,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
} else if (hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
@@ -4076,7 +4181,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Without geometry there is no cursor move, so fall back to the old trim.
// Keeping the blank rows here would park the caret at the bottom of the
// pane with nothing to correct it — worse than not trying at all.
if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\n+$/g, ''));
if (!geometry) {
const trimmed = rawCapture.replace(/\n+$/g, '');
return trimmed ? `${normalizeScrollbackEol(trimmed)}\x1b[0m` : '';
}
// Take the line terminator off and nothing else. The trailing blank rows
// that remain are the real bottom of the screen, and the cursor move
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
@@ -4090,7 +4198,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
// side where that guard cannot see it.
if (!hasVisibleContent(trimmed)) return '';
return `${normalizeScrollbackEol(trimmed)}${formatCursorRestore(geometry)}`;
// capture-pane -e can leave the last row's SGR active (e.g. a nested
// tmux status bar). Hand live output the same default style as a visible
// snapshot, resetting before the cursor move so its position is kept.
return `${normalizeScrollbackEol(trimmed)}\x1b[0m${formatCursorRestore(geometry)}`;
}
const buffer = rawCapture.replace(/\n+$/g, '');
@@ -4099,7 +4210,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
return normalizeScrollbackEol(buffer);
return buffer ? `${normalizeScrollbackEol(buffer)}\x1b[0m` : '';
} catch (err) {
// ENOBUFS carries the truncated multi-MB stdout on the error object —
// log a concise line instead of dumping it into the journal.
+34 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -17,6 +17,7 @@
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
*
* Cross-domain relationships:
@@ -56,7 +57,8 @@ export type SessionMode =
| 'pi'
| 'grok'
| 'deepseek'
| 'omp';
| 'omp'
| 'copilot';
/**
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
@@ -78,7 +80,7 @@ export interface SessionWriteOptions {
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
>;
/**
@@ -213,7 +215,7 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -525,6 +527,32 @@ export interface GrokConfig {
resumeSessionId?: string;
}
/**
* GitHub Copilot CLI (`copilot`) session configuration.
*
* Copilot asks before running tools ("Manual Approval") and before touching paths
* outside the trusted folder. `--yolo` is its allow-all switch (tools, paths and URLs),
* the one the Run button opts into like the other agent CLIs' bypass switches. Verified
* against copilot 1.0.94.
*/
export interface CopilotConfig {
/** Model ID passed via --model (e.g. "claude-sonnet-5.5", or "auto"). */
model?: string;
/**
* Allow every tool, path and URL without asking (passes --yolo). Absent = Copilot's own
* Manual Approval mode. Multi-user: forced off for non-granted owners by the only-if-sent
* clamp branch, like codex/antigravity/grok.
*/
allowAll?: boolean;
/** Resume the most recent session (--continue). Skipped when resumeSessionId is set. */
continueSession?: boolean;
/**
* Resume a specific session (--resume <value>): an id, id prefix or session name, as ONE plain word
* starting with a letter or digit (a leading `-` would make the value its own flag).
*/
resumeSessionId?: string;
}
/**
* DeepSeek Harness (`dsh`) session configuration.
*
@@ -893,6 +921,8 @@ export interface SessionState {
piConfig?: PiConfig;
/** Grok-specific configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** GitHub Copilot CLI configuration (only for mode === 'copilot') */
copilotConfig?: CopilotConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** OMP-specific configuration (only for mode === 'omp') */
+4 -1
View File
@@ -7,7 +7,7 @@
* and the badge could disagree with the Run menu.
*
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
* keep the nine resolvers out of any module that never asks.
* keep the ten resolvers out of any module that never asks.
*/
import type { CliEntry } from '../config/cli-registry/types.js';
@@ -30,6 +30,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
{ isGrokAvailable },
{ isDeepSeekRunnable },
{ isOmpAvailable },
{ isCopilotAvailable },
] = await Promise.all([
import('./claude-cli-resolver.js'),
import('./opencode-cli-resolver.js'),
@@ -40,6 +41,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
import('./grok-cli-resolver.js'),
import('./deepseek-cli-resolver.js'),
import('./omp-cli-resolver.js'),
import('./copilot-cli-resolver.js'),
]);
return {
claude: isClaudeAvailable(),
@@ -51,6 +53,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
grok: isGrokAvailable(),
deepseek: isDeepSeekRunnable(),
omp: isOmpAvailable(),
copilot: isCopilotAvailable(),
};
}
+139
View File
@@ -0,0 +1,139 @@
/**
* @fileoverview Resolve the GitHub Copilot CLI binary across common install paths.
*
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
* server process PATH first, then common install directories, then — last,
* because it is the only step that spawns anything — an interactive login
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
* as a systemd/launchd service with a minimal PATH.
*
* Provides an augmented PATH directory for tmux sessions.
*
* @module utils/copilot-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const COPILOT_SEARCH_DIRS = (): string[] => (getCli('copilot')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `copilot --version` prints `GitHub Copilot CLI 1.0.94.`. Anchoring on the product
* name keeps an unrelated `copilot` binary on PATH from being accepted.
*/
export const COPILOT_VERSION_REGEX = /GitHub Copilot CLI (\d+\.\d+\.\d+)/;
const COPILOT_NOT_FOUND = 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot';
/**
* Run `copilot --version` on a candidate path and return the trimmed version when
* it is the GitHub Copilot CLI. Returns null for anything else — a missing
* binary, a non-zero exit, a hang (timeout), or output that is not
* `GitHub Copilot CLI <semver>`-shaped (which is how an unrelated `copilot` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have copilot installed. The shared resolver host
* is already inert under vitest, so this gate is defense in depth for any
* opted-in host that still carries the default probe.
*/
function probeCopilotVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `copilot` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
const candidate = COPILOT_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(
`[CopilotResolver] Ignoring ${binPath}: "copilot --version" printed ${JSON.stringify(out.slice(0, 80))}`
);
} catch (err) {
console.warn(`[CopilotResolver] Ignoring ${binPath}: "copilot --version" failed (${(err as Error).message})`);
}
return null;
}
type CopilotVersionProbe = (binPath: string) => string | null;
function createCopilotResolver(
host?: CliResolverHost,
versionProbe: CopilotVersionProbe = probeCopilotVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'copilot',
searchDirs: COPILOT_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated Copilot wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createCopilotResolverForTest(
host: CliResolverHost,
versionProbe?: CopilotVersionProbe,
now?: () => number
) {
return createCopilotResolver(host, versionProbe ?? probeCopilotVersion, now);
}
const copilotResolver = createCopilotResolver();
/**
* Finds the directory containing a verified `copilot` binary.
* Checks `which copilot` first, then falls back to common install locations. Every
* candidate must pass the `copilot --version` sanity probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveCopilotDir(): string | null {
return copilotResolver.resolve()?.directory ?? null;
}
/**
* Check if the GitHub Copilot CLI is available on the system.
*/
export function isCopilotAvailable(): boolean {
return resolveCopilotDir() !== null;
}
export function getCopilotNotFoundMessage(): string {
return formatCliNotFoundMessage(COPILOT_NOT_FOUND, copilotResolver.diagnostics());
}
/**
* Version reported by the resolved `copilot` binary, or null when copilot is
* unavailable. Surfaced through `GET /api/copilot/status` so a misresolution is
* diagnosable from the UI.
*/
export function getCopilotCliVersion(): string | null {
return copilotResolver.resolve()?.metadata ?? null;
}
+2 -1
View File
@@ -84,7 +84,8 @@ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0
*/
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
const DEEPSEEK_NOT_FOUND =
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)';
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
export function resolveDshHome(): string {
+6
View File
@@ -69,6 +69,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
export {
resolveCopilotDir,
isCopilotAvailable,
getCopilotNotFoundMessage,
getCopilotCliVersion,
} from './copilot-cli-resolver.js';
export {
boundedPathExists,
describeUnknownPath,
+2
View File
@@ -26,6 +26,8 @@
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
## Frontend modules and UI subsystems
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
+13 -4
View File
@@ -12,8 +12,10 @@
* its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached
* through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way
* around it;
* - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same
* folder listed both as a local case and as a linked one would make deleting it remove files;
* - it must not be a cases directory or a direct child of one: those are what `GET /api/cases` lists
* as local cases, so a case there is a plain Create New, and the same folder listed both as a local
* case and as a linked one would make deleting it remove files. A folder further down is not
* listed and is allowed;
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
@@ -98,9 +100,16 @@ export function blockedReason(
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
}
// Only the cases folder itself and its DIRECT children are listed as local cases (GET /api/cases reads
// that one level), so only those would be registered twice. A deeper folder is not listed and is a
// perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts,
// refusing everything below it blocked most of the tree.
for (const dir of ctx.casesDirs ?? []) {
if (isWithin(absPath, dir)) {
return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)';
if (absPath === dir) {
return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`;
}
if (dirname(absPath) === dir) {
return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; create it there with plain Create New (no custom folder)`;
}
}
return null;
+152 -44
View File
@@ -533,8 +533,8 @@ const DEFAULT_SHORTCUTS = [
group: 'Session',
label: 'Toggle Session Sidebar',
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
// readline backward-char). The Alt block below claims only Digit1-9 and
// the brackets, and the registry claims Alt for KeyK and Slash only.
// readline backward-char). The Alt block below claims only Digit1-9; the
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
@@ -638,11 +638,22 @@ const DEFAULT_SHORTCUTS = [
bindings: [],
action: 'removeTile',
},
// Physical key codes, so macOS Option layouts that print "“" for Option+[ still match.
{
id: 'previous-next-session',
id: 'previous-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
label: 'Previous Session',
displayBindings: ['Alt/Option+['],
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
action: 'previousSession',
},
{
id: 'next-session-alt',
group: 'Session',
label: 'Next Session (alternate)',
displayBindings: ['Alt/Option+]'],
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
action: 'nextSession',
},
{
id: 'switch-tab-n',
@@ -688,6 +699,12 @@ const DEFAULT_SHORTCUTS = [
* setInterval fire on every frame, and constants.js is cached independently.
*/
const SIDEBAR_RICH_CLOCK_MS = 20000;
// Header connection pill: a queue above CONNECTION_BACKLOG_HINT_BYTES is only worth a " · NB queued"
// suffix once it has STAYED above it for CONNECTION_BACKLOG_HOLD_MS. A paste, an IME commit or a fast
// word sits above the threshold for a few milliseconds until its ACK lands, and surfacing that made the
// pill grow to the right and shrink back on every such burst.
const CONNECTION_BACKLOG_HINT_BYTES = 4;
const CONNECTION_BACKLOG_HOLD_MS = 1200;
/**
* How long a `#session=<id>` link waits for the session list to name its id
@@ -1388,19 +1405,7 @@ class CodemanApp {
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
const SHORTCUT_ACTIONS = this._shortcutActions();
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
@@ -1447,6 +1452,7 @@ class CodemanApp {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeWorktreeModal?.();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
@@ -1463,7 +1469,8 @@ class CodemanApp {
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the
// shortcut registry, so they can be rebound.
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const code = e.code || '';
const digitMatch = code.match(/^Digit([1-9])$/);
@@ -1489,16 +1496,6 @@ class CodemanApp {
}
return;
}
if (e.code === 'BracketLeft') {
e.preventDefault();
this.prevSession();
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
this.nextSession();
return;
}
}
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
@@ -3017,9 +3014,11 @@ class CodemanApp {
? 'DeepSeek'
: mode === 'omp'
? 'OMP'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'copilot'
? 'GitHub Copilot'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -4373,19 +4372,26 @@ class CodemanApp {
}
}
// Pure render of the header connection indicator: reads only `this.*` state,
// Pure render of the header connection indicator: reads only `this.*` state (and
// the pending totals it is handed, which default to a fresh `_pendingBytes()`),
// touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
// writer applies. When hidden (display:'none') the other three are normalized to
// '' so the cache compare in _updateConnectionIndicator() is well-defined.
// Every branch/string here must stay byte-identical to what's rendered today.
_computeConnectionDescriptor() {
const { bytes: totalBytes, count } = this._pendingBytes();
_computeConnectionDescriptor(pending = this._pendingBytes()) {
const { bytes: totalBytes, count } = pending;
const hasQueue = count > 0;
// Only surface a backlog once it's more than a few bytes. A single keystroke
// (1B) ACKs in milliseconds, so without this the label flickered "sending 1B"
// on every key press. Above this threshold means input is genuinely backing up.
const BACKLOG_HINT_BYTES = 4;
const showBacklog = totalBytes > BACKLOG_HINT_BYTES;
// on every key press. Above this threshold means input is genuinely backing up,
// and only once it has stayed there (see CONNECTION_BACKLOG_HOLD_MS): a burst
// that drains in a moment must not widen the pill. `_backlogOverSince` is kept
// by _updateConnectionIndicator; `undefined` (never tracked, as when this pure
// compute is called directly) means no hold.
const since = this._backlogOverSince;
const heldLongEnough =
since === undefined || (since !== null && Date.now() - since >= CONNECTION_BACKLOG_HOLD_MS);
const showBacklog = totalBytes > CONNECTION_BACKLOG_HINT_BYTES && heldLongEnough;
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
const queuedSuffix = showBacklog ? ` · ${formatBytes(totalBytes)} queued` : '';
@@ -4501,10 +4507,35 @@ class CodemanApp {
const text = this.$('connectionText');
if (!indicator || !dot || !text) return;
// Track how long the queue has stayed above the hint threshold, and make sure
// the pill is re-rendered when the hold elapses even if no keystroke or ACK
// arrives to trigger it.
// ⚠️ This runs before _drainSession() on every keystroke, so it must never throw: a throw here
// once skipped immediate delivery and made typing lag (COD-133; the combinatorial never-throws
// test in test/connection-indicator.test.ts pins it). The queue is walked ONCE per call and the
// same totals are handed to the compute below, since this is the hot input path.
const pending = this._pendingBytes();
if (pending.bytes > CONNECTION_BACKLOG_HINT_BYTES) {
if (this._backlogOverSince == null) this._backlogOverSince = Date.now();
if (!this._backlogHoldTimer && Date.now() - this._backlogOverSince < CONNECTION_BACKLOG_HOLD_MS) {
const wait = CONNECTION_BACKLOG_HOLD_MS - (Date.now() - this._backlogOverSince) + 25;
this._backlogHoldTimer = setTimeout(() => {
this._backlogHoldTimer = null;
this._updateConnectionIndicator();
}, wait);
}
} else {
this._backlogOverSince = null;
if (this._backlogHoldTimer) {
clearTimeout(this._backlogHoldTimer);
this._backlogHoldTimer = null;
}
}
// Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery).
// During fast typing the rendered tuple is usually identical, so skip the DOM
// writes when nothing changed (COD-136) — the compute above is DOM-free.
const next = this._computeConnectionDescriptor();
const next = this._computeConnectionDescriptor(pending);
const prev = this._lastIndicatorDescriptor;
// The tile's value word is written in the UI language, so a language
// switch counts as a change too.
@@ -4843,6 +4874,8 @@ class CodemanApp {
}
}
if (data.version) this.checkWhatsNew?.(data.version);
// Stop any active voice recording on reconnect
VoiceInput.cleanup();
@@ -10336,9 +10369,10 @@ class CodemanApp {
delete this.respawnActionLogs[sessionId];
}
/** Resolves true once the session is gone from the server, false when it is still there. */
async closeSession(sessionId, killMux = true) {
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
if (this._closingSessions.has(sessionId)) return false;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
@@ -10416,7 +10450,7 @@ class CodemanApp {
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
return;
return true;
}
// Still on the server (or the server is unreachable, in which case the resync
@@ -10431,6 +10465,7 @@ class CodemanApp {
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
return false;
}
// Request confirmation before closing a session
@@ -10445,6 +10480,20 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// A session in a Codeman-managed git worktree also offers to remove the worktree
// (git worktree remove, never forced) once the session is gone. Read now, before the
// session leaves this.sessions.
const worktree = this._managedWorktreeOfSession?.(session) || null;
this.pendingCloseWorktree = worktree;
const wtOption = document.getElementById('closeConfirmWorktree');
if (wtOption) {
wtOption.hidden = !worktree;
const wtName = document.getElementById('closeConfirmWorktreeName');
if (wtName) wtName.textContent = worktree ? worktree.name : '';
const wtBranch = document.getElementById('closeConfirmWorktreeBranch');
if (wtBranch) wtBranch.checked = false;
}
// Update kill button text based on session mode
const killTitle = document.getElementById('closeConfirmKillTitle');
if (killTitle) {
@@ -10464,7 +10513,9 @@ class CodemanApp {
? 'Kill Tmux & DeepSeek'
: session.mode === 'omp'
? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code';
: session.mode === 'copilot'
? 'Kill Tmux & GitHub Copilot'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
@@ -10472,15 +10523,24 @@ class CodemanApp {
cancelCloseSession() {
this.pendingCloseSessionId = null;
this.pendingCloseWorktree = null;
document.getElementById('closeConfirmModal').classList.remove('active');
}
async confirmCloseSession(killMux = true) {
/**
* `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only
* after the server confirms the session is gone (a live session inside is refused anyway),
* and only ever through `git worktree remove` without --force.
*/
async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) {
const sessionId = this.pendingCloseSessionId;
const worktree = removeWorktree ? this.pendingCloseWorktree : null;
const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked;
this.cancelCloseSession();
if (sessionId) {
await this.closeSession(sessionId, killMux);
const gone = await this.closeSession(sessionId, killMux);
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
}
}
@@ -10698,6 +10758,54 @@ class CodemanApp {
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
/**
* Action name -> handler for the shortcut registry entries the document keydown
* handler dispatches generically (DEFAULT_SHORTCUTS + the user's rebinds). Entries
* absent here (copy selection, the palette, the tile chords) have their own dispatch.
*/
_shortcutActions() {
return {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
previousSession: () => this.prevSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
toggleSessionSidebar: () => this.toggleSessionSidebar(),
};
}
/**
* True when `e` is a chord the USER rebound to a generically dispatched action.
* The document handler has already run the action, but its preventDefault() does not
* stop xterm, so without this gate a rebind to e.g. Alt+J would also type ESC j into the
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY,
* and neither is a chord that matches the entry's own default (a re-capture of it).
*/
isUserBoundShortcutEvent(e) {
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
const overrides = this.loadAppSettingsFromStorage().shortcutOverrides || {};
const actions = this._shortcutActions();
return this.getShortcutRegistry().some((shortcut) => {
if (shortcut.disabled || !Array.isArray(overrides[shortcut.id]?.bindings)) return false;
// toggleSessionSidebar has its own gate (shouldToggleSessionSidebarFromShortcut) that
// lets the chord through when the sidebar layout is off; swallowing it here would make a
// rebound sidebar key dead in the header layout.
if (shortcut.action === 'toggleSessionSidebar') return false;
if (typeof actions[shortcut.action] !== 'function') return false;
if (!this.matchesShortcutEvent(e, shortcut)) return false;
// Re-capturing the entry's own default (Ctrl+L for Clear Terminal) must keep reaching the
// CLI: clearTerminal() relies on ^L for the redraw.
const fallback = DEFAULT_SHORTCUTS.find((d) => d.id === shortcut.id);
return !this.matchesShortcutEvent(e, fallback);
});
}
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
+156 -2
View File
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2100,7 +2100,7 @@ function previewsInFileViewer(filePath) {
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
@@ -3436,3 +3436,157 @@ if (typeof window !== 'undefined') {
TERMINAL_MIN_ROWS,
};
}
// ═══════════════════════════════════════════════════════════════
// Security notices
// ═══════════════════════════════════════════════════════════════
// A security notice is a sticky warning toast about something outside Codeman that
// its users should act on (the first: OpenCode's Console breach, October 2026). The
// notices are DATA in the hand-maintained src/web/public/security-notices.json, never
// in whats-new.json (`npm run version-packages` rewrites that one every release), and
// they ship with a release. This is the pure half: which notices this browser shows, and
// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js,
// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js.
/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */
const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */
const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/;
/**
* The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one.
* Local time on purpose: a notice dated the 18th starts on the viewer's 18th.
*/
function securityNoticeDay(value) {
const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null;
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]) - 1;
const day = Number(match[3]);
const start = new Date(year, month, day);
if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null;
const end = new Date(year, month, day + 1).getTime() - 1;
return { start: start.getTime(), end };
}
function securityNoticeText(value) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
/**
* The notices to show, in file order, each as `{ id, title, message, label, url }`
* (`title` is '' when the entry has none).
*
* - A malformed entry is skipped, never repaired: no id, message or link label, a
* link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title`
* that is present but not a non-blank string, or a `cli` that is not a registry
* id. A repeated id shows its first entry only.
* - Shown from the start of the `published` day to the END of the `expires` day.
* The expiry is what keeps an old notice from alarming a browser that first opens
* Codeman months later.
* - An id in `seen` is skipped (the user closed it, or followed its link).
* - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent,
* unknown and false all mean "not installed" here, unlike isCliAvailable() in
* settings-ui.js (which offers a launch on a doubt): a warning about software you
* do not have is noise. A notice with no `cli` is for everyone.
* - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where
* they exist, each falling back to the English one on its own.
*
* The CLI id is data from the JSON, compared as a key; no code here names one.
*
* @param {Array<object>} notices - `notices` from security-notices.json
* @param {{available?: Record<string, boolean>, seen?: Iterable<string>, now?: number, lang?: string}} [opts]
* `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()).
* @returns {Array<{id: string, title: string, message: string, label: string, url: string}>}
*/
function selectSecurityNotices(notices, opts = {}) {
if (!Array.isArray(notices)) return [];
const available = opts.available && typeof opts.available === 'object' ? opts.available : {};
const seenSource = opts.seen;
const seen = new Set(
seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function'
? Array.from(seenSource, String)
: []
);
const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now();
const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en';
const taken = new Set();
const shown = [];
for (const entry of notices) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const id = securityNoticeText(entry.id);
const title = securityNoticeText(entry.title);
const message = securityNoticeText(entry.message);
const link = entry.link && typeof entry.link === 'object' ? entry.link : null;
const label = securityNoticeText(link?.label);
const url = typeof link?.url === 'string' ? link.url.trim() : '';
const published = securityNoticeDay(entry.published);
const expires = securityNoticeDay(entry.expires);
if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue;
if (entry.title !== undefined && !title) continue;
if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue;
if (taken.has(id)) continue;
taken.add(id);
if (now < published.start || now > expires.end) continue;
if (seen.has(id)) continue;
if (entry.cli !== undefined && available[entry.cli] !== true) continue;
const localized = lang === 'en' ? null : entry.i18n?.[lang];
shown.push({
id,
title: securityNoticeText(localized?.title) || title,
message: securityNoticeText(localized?.message) || message,
label: securityNoticeText(localized?.label) || label,
url,
});
}
return shown;
}
/**
* Which surface carries each selected notice on this page load, decided together with
* the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets
* ONE notification, never a card and a toast for the same notice.
*
* - The update toast shows exactly as checkWhatsNew always decided: this browser saw an
* older version (`seenVersion` set and different), and `notes` are this version's with
* at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`)
* and no standalone card is shown.
* - Otherwise (a fresh install, a reload on the same version, notes missing or empty)
* each notice gets its own card (`cards`).
* - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a
* fresh install (so it is not greeted with "what's new") and when the toast shows.
* Notes that are missing or empty record nothing, so the next load tries again.
*
* @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array<object>}} state
* `notices` is the output of selectSecurityNotices().
* @returns {{recordVersion: boolean, update: boolean, updateNotices: Array<object>, cards: Array<object>}}
*/
function planStartupNotices(state = {}) {
const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null;
const runningVersion = state.runningVersion;
const notices = Array.isArray(state.notices) ? state.notices : [];
const notes = state.notes;
const update = Boolean(
seenVersion &&
seenVersion !== runningVersion &&
notes &&
notes.version === runningVersion &&
Array.isArray(notes.highlights) &&
notes.highlights.length > 0
);
return {
recordVersion: !seenVersion || update,
update,
updateNotices: update ? notices.slice() : [],
cards: update ? [] : notices.slice(),
};
}
if (typeof window !== 'undefined') {
window.CodemanSecurityNotices = {
select: selectSecurityNotices,
plan: planStartupNotices,
// The same https-only rule select() applies, for the card's link button to check again.
urlOk: (url) => typeof url === 'string' && SECURITY_NOTICE_URL_PATTERN.test(url),
};
}
+79 -3
View File
@@ -168,8 +168,8 @@
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
// view's drop (image-input.js) uses 'Only image or video files are supported'.
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
// view (image-input.js).
'Unsupported file type': '不支持的文件类型',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
@@ -254,6 +254,7 @@
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP',
'Run GitHub Copilot': '运行 GitHub Copilot',
'Run Shell': '运行 Shell',
'More tools': '更多工具',
'Select AI backend': '选择 AI 后端',
@@ -265,6 +266,50 @@
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
// phone case sheet and Close Session's worktree option). Worktree and branch names,
// paths and git's own messages are user/git text and pass through untranslated.
'Git worktrees…': 'Git 工作树…',
'Git worktrees': 'Git 工作树',
'Close git worktrees': '关闭 Git 工作树',
Worktrees: '工作树',
'Worktrees…': '工作树…',
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
'Start sessions with': '启动会话所用',
'New worktree': '新建工作树',
Branch: '分支',
'Start from': '起点',
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'Branch and start point': '分支和起点',
"A branch, tag or commit. Default: the case's current commit (HEAD).":
'分支、标签或提交。默认:案例当前提交(HEAD)。',
'Create & start session': '创建并启动会话',
'Loading worktrees…': '正在加载工作树…',
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
'detached HEAD': '游离 HEAD',
'main checkout': '主检出',
'not created by Codeman': '非 {name} 创建',
locked: '已锁定',
missing: '已丢失',
Open: '打开',
'Start a session in this worktree': '在此工作树中启动会话',
'Remove…': '移除…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
'Remove worktree': '移除工作树',
'Kill & remove worktree': '终止并移除工作树',
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
'Also delete its branch (git branch -d keeps a branch that is not merged)':
'同时删除其分支(git branch -d 会保留未合并的分支)',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
'A launch is already in progress': '已有启动正在进行',
'Codeman did not answer.': '{name} 没有响应。',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -634,6 +679,9 @@
'Claude Model': 'Claude 模型',
'1M Opus Context': 'Opus 100 万上下文',
'Remote auto-reconnect': '远程自动重连',
'Pin tab names on the CLI': '在 CLI 上固定标签名',
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
'以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
'Thinking Effort': '思考强度',
Low: '低',
Medium: '中',
@@ -691,7 +739,7 @@
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'Only image or video files are supported': '仅支持图片或视频文件',
'📎 Attach': '📎 添加附件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
@@ -798,9 +846,19 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
// The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice);
// each notice's title, message and link label carry their own zh-CN text and are skipped.
'Security notice': '安全提示',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
// writes are admin-only there.
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
@@ -1179,6 +1237,8 @@
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
'Next Session (alternate)': '下一个会话(备用)',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
@@ -1244,6 +1304,10 @@
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
@@ -1252,6 +1316,18 @@
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
// words pass through untranslated.
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
[
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
],
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
+41 -22
View File
@@ -101,12 +101,8 @@ Object.assign(CodemanApp.prototype, {
if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
if (mediaFiles.length === 0) {
this.showToast('Only image or video files are supported', 'error');
return;
}
this._uploadAndInsertImages(mediaFiles);
const mediaFiles = this._admitPromptAttachments(Array.from(e.dataTransfer.files));
if (mediaFiles.length > 0) this._uploadAndInsertImages(mediaFiles);
});
},
@@ -122,11 +118,8 @@ Object.assign(CodemanApp.prototype, {
* Without the overlay (local echo off) there is nothing to buffer into, so the
* text goes straight out. The trailing space keeps the next word off the path. */
async _attachFromToolbar(files, sessionId) {
const media = files.filter((f) => this._promptAttachKind(f));
if (media.length === 0) {
this.showToast('Only image or video files are supported', 'error');
return;
}
const media = this._admitPromptAttachments(files);
if (media.length === 0) return;
if (!sessionId) return; // no captured session: refuse rather than guess at one
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
if (paths.length === 0) return;
@@ -230,15 +223,17 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images or videos in clipboard items
var imageFiles = [];
// Check for images, videos or documents in clipboard items
var pastedFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
var blob = items[i].getAsFile();
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
if (blob) pastedFiles.push(blob);
}
}
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
var imageFiles = self._admitPromptAttachments(pastedFiles, { textPasted: !!text });
// Clean up the trap
setTimeout(function() {
@@ -256,7 +251,6 @@ Object.assign(CodemanApp.prototype, {
// via raw sendInput() strips those markers and makes pasted input
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
if (text && terminal) terminal.paste(text);
}
});
@@ -266,21 +260,46 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/** What a pick or a paste may upload: 'image', or 'video' for the camera
* roll's clips (the server streams those to disk under their own cap), else
* null. The one filter behind Attach, drop, the Compose and paste-dialog
* pickers and the clipboard handlers. The MIME type decides; some Android
* file managers hand over a File with none, so the name's extension is the
* fallback, mirroring the server's own allowlists. */
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
* clips (the server streams those to disk under their own cap), 'document'
* for the office and text files the server admits, else null. The one filter
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
* handlers. For media the MIME type decides, but a video MIME only for the
* containers the server admits; some Android file managers hand over a File
* with none, so the name's extension is the fallback. A document is known by
* its extension alone, as on the server (the MIME a file manager gives a .md
* or .csv is anyone's guess). Mirrors the server's allowlists. */
_promptAttachKind(file) {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
// A video MIME counts only for the five containers the server accepts on
// ?kind=video (its own set in session-routes.ts); a 3gp or mkv falls to the
// extension check below and is refused up front, not after a 1GB upload and a 400.
if (/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/i.test(file.type)) return 'video';
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
return 'document';
}
return null;
},
/** The files of a pick, drop or paste that _promptAttachKind admits, telling the
* user about any it refuses: all of them is "Unsupported file type", some of
* them a count of what was skipped while the rest still upload. A clipboard
* paste that also carries text passes `textPasted`: when none of its files is
* admitted the text is pasted instead, so that case stays quiet. */
_admitPromptAttachments(files, { textPasted = false } = {}) {
const admitted = files.filter((f) => this._promptAttachKind(f));
const skipped = files.length - admitted.length;
if (skipped > 0 && admitted.length > 0) {
this.showToast(`${skipped} file${skipped > 1 ? 's' : ''} skipped: unsupported type`, 'info');
} else if (skipped > 0 && !textPasted) {
this.showToast('Unsupported file type', 'error');
}
return admitted;
},
// Max images accepted in one batch (paste / drop / mobile picker). Each is
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
// rate limit while covering "select a bunch of photos at once".
+74 -2
View File
@@ -790,7 +790,7 @@
<path d="m21 15-5-5L5 21"/>
</svg>
</button>
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple tabindex="-1" aria-hidden="true">
</span>
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
title="Voice input" aria-label="Start voice input" aria-pressed="false">
@@ -1078,6 +1078,7 @@
<option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option>
<option value="omp">OMP</option>
<option value="copilot">GitHub Copilot</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -1693,6 +1694,18 @@
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
app.js): kill it, then `git worktree remove` without --force. -->
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
<span class="close-option-title">Kill &amp; remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
</button>
<label class="close-option-worktree-branch">
<input type="checkbox" id="closeConfirmWorktreeBranch">
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
</label>
</div>
</div>
<div class="modal-footer-cancel">
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
@@ -1700,6 +1713,52 @@
</div>
</div>
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
<div class="modal-content worktree-modal">
<div class="modal-header">
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint worktree-intro">Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.</p>
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
<div class="worktree-list" id="worktreeList"></div>
<div class="form-row worktree-mode-row">
<label for="worktreeMode">Start sessions with</label>
<select id="worktreeMode" class="form-select"></select>
</div>
<fieldset class="worktree-new">
<legend>New worktree</legend>
<div class="form-row">
<label for="worktreeName">Name</label>
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
<span class="form-hint">New branch worktree-&lt;name&gt; from the case's current commit, unless you pick another below.</span>
</div>
<details class="worktree-advanced">
<summary>Branch and start point</summary>
<div class="form-row">
<label for="worktreeBranch">Branch</label>
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label for="worktreeBase">Start from</label>
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
</div>
</details>
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
</fieldset>
</div>
<div class="form-actions">
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create &amp; start session</button>
</div>
</div>
</div>
<!-- App Settings Modal -->
<div class="modal" id="appSettingsModal">
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
@@ -2729,6 +2788,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="pin cli session name resume title tab label">
<div class="set-row-text">
<span class="set-row-label">Pin tab names on the CLI</span>
<span class="set-row-desc">Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPinCliSessionName"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="remote auto reconnect ssh">
<div class="set-row-text">
<span class="set-row-label">Remote auto-reconnect</span>
@@ -3400,6 +3466,7 @@
<option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="omp" data-cli="omp">OMP</option>
<option value="copilot" data-cli="copilot">GitHub Copilot</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -3578,7 +3645,7 @@
<div class="form-row docker-create-only">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/copilot + tmux.</span>
</div>
<div class="form-row docker-create-only">
<label>Network</label>
@@ -3663,6 +3730,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create New Case
</button>
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
<span aria-hidden="true">&#x2442;</span>
Worktrees
</button>
</div>
</div>
</div>
+40 -29
View File
@@ -1282,11 +1282,11 @@ const KeyboardAccessoryBar = {
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-image">📎 Attach</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1339,8 +1339,9 @@ const KeyboardAccessoryBar = {
this._syncComposerDraftIndicator();
close({ preserveDraft: false });
};
const handleImages = async (files) => {
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
// Takes files already admitted by app._admitPromptAttachments (the picker and
// the paste below), which also toasts whatever it refused.
const handleImages = async (images) => {
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
saveDraft();
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
@@ -1354,7 +1355,7 @@ const KeyboardAccessoryBar = {
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
@@ -1381,7 +1382,6 @@ const KeyboardAccessoryBar = {
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
else this._composerUploads.delete(sessionId);
syncUploadUi();
if (overlay.isConnected) fileInput.value = '';
}
};
@@ -1389,9 +1389,11 @@ const KeyboardAccessoryBar = {
textarea.addEventListener('paste', (event) => {
const items = event.clipboardData?.items;
if (!items) return;
const images = Array.from(items)
const files = Array.from(items)
.map((item) => item.getAsFile())
.filter((file) => file && app._promptAttachKind(file));
.filter(Boolean);
// Pasted text with no admitted file lands in the textarea natively: no toast.
const images = app._admitPromptAttachments(files, { textPasted: !!event.clipboardData.getData('text/plain') });
if (images.length > 0) {
event.preventDefault();
void handleImages(images);
@@ -1404,7 +1406,12 @@ const KeyboardAccessoryBar = {
}
});
imageButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
fileInput.addEventListener('change', () => {
const files = Array.from(fileInput.files || []);
// Reset so picking the same refused file again still fires change (and its toast).
fileInput.value = '';
void handleImages(app._admitPromptAttachments(files));
});
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
// Hand the text BACK to the terminal prompt so both surfaces hold the same
// prompt, the mirror of the adoption composePrompt() does on open. Only a
@@ -1469,13 +1476,13 @@ const KeyboardAccessoryBar = {
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/*,video/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* - File (picker): the "Attach" button opens a native file picker
* (accept=image/*,video/* plus the document extensions → camera / photo
* library / files), the most reliable way to attach anything on mobile.
* - File (paste): if the browser exposes file blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -1488,13 +1495,13 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-image">📎 Attach</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1519,10 +1526,11 @@ const KeyboardAccessoryBar = {
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
};
// Filter to images and videos, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
// Filter to what the server admits (app._admitPromptAttachments toasts what it
// refuses), close the dialog, and hand off to the shared upload+insert
// pipeline. Returns true if any file was handled.
const handleImages = (files, options) => {
const images = app._admitPromptAttachments(Array.from(files || []), options);
if (images.length === 0) return false;
close();
// Upload to, and insert into, the shell this overlay was opened for.
@@ -1530,23 +1538,26 @@ const KeyboardAccessoryBar = {
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
// File picker (camera / photo library / files) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
fileInput.addEventListener('change', () => {
const files = Array.from(fileInput.files || []);
// Reset so picking the same refused file again still fires change (and its toast).
fileInput.value = '';
handleImages(files);
});
// Best-effort: capture images pasted straight into the textarea.
textarea.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const imageFiles = [];
const files = [];
for (let i = 0; i < items.length; i++) {
const blob = items[i].getAsFile();
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
}
if (imageFiles.length > 0) {
e.preventDefault();
handleImages(imageFiles);
if (blob) files.push(blob);
}
// Pasted text with no admitted file lands in the textarea natively: no toast.
if (handleImages(files, { textPasted: !!e.clipboardData.getData('text/plain') })) e.preventDefault();
});
overlay.querySelector('.paste-cancel').addEventListener('click', close);
+1
View File
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'omp', label: 'OMP', short: 'OMP' },
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
+20
View File
@@ -1240,6 +1240,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(129, 140, 248, 0.5) !important;
}
/* GitHub Copilot mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
.btn-toolbar.btn-run.mode-copilot,
.btn-toolbar.btn-run-gear.mode-copilot {
background: #2e1065 !important;
border-color: rgba(163, 113, 247, 0.3) !important;
color: #ede9fe !important;
}
.btn-toolbar.btn-run.mode-copilot:active,
.btn-toolbar.btn-run-gear.mode-copilot:active {
background: #6d28d9 !important;
border-color: rgba(163, 113, 247, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -3337,6 +3351,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-copilot, .btn-toolbar.btn-run-gear.mode-copilot) {
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
border-color: #5b21b6;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
+185 -11
View File
@@ -533,7 +533,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
@@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, {
closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
toast.appendChild(closeBtn);
// Cache toast container reference
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
this._toastContainer.appendChild(toast);
this._toastStack().appendChild(toast);
requestAnimationFrame(() => toast.classList.add('show'));
@@ -6272,6 +6263,189 @@ Object.assign(CodemanApp.prototype, {
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } };
},
/** The fixed toast stack (`.toast-container`), created on first use and cached. */
_toastStack() {
if (!this._toastContainer) {
this._toastContainer = document.querySelector('.toast-container');
if (!this._toastContainer) {
this._toastContainer = document.createElement('div');
this._toastContainer.className = 'toast-container';
document.body.appendChild(this._toastContainer);
}
}
return this._toastContainer;
},
/**
* A security notice (security-notices.json) as its own card in the toast stack: shield,
* "Security notice" eyebrow, the notice's title and message, its link as the primary
* button, a quiet Dismiss and the ×. Same container as every toast, so it never fights
* the z-index stack. Sticky: only the user ends it.
*
* The notice's own text (title, message, link label) is already in the UI language, so it
* is set with textContent and carries `data-i18n-skip`; the card's own copy ("Security
* notice", "Dismiss", the × label) is application text and translates as usual.
*
* `opts.onClose(reason)` fires ONCE, and only on a user action: `'link'`, `'dismiss'` or
* `'close'`. Never on the handle's own `dismiss()`, which does not mean the user saw it.
*
* @param {{id: string, title: string, message: string, label: string, url: string}} notice
* @param {{onClose?: (reason: string) => void}} [opts]
*/
showSecurityNotice(notice, opts = {}) {
const card = this._noticeCard('security-notice', opts.onClose);
const main = this._noticeEl('div', 'security-notice-main');
main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice'));
main.append(...this._noticeTextBlock(notice));
const actions = this._noticeEl('div', 'security-notice-actions');
const link = this._noticeLinkButton(notice, card, () => card.close('link'));
if (link) actions.append(link);
actions.append(this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss')));
main.append(actions);
card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton);
card.mount();
return { dismiss: () => card.close(null) };
},
/**
* The once-per-release update toast when security notices ride INSIDE it
* (CodemanSecurityNotices.plan in constants.js decides that; with no notice the plain
* showToast toast is used, unchanged). Styled as the security card: the release text as
* before (translated by the same i18n.js pattern), one warning section per notice, then
* each notice's link button beside "See what's new".
*
* Sticky, since it carries a warning. `onNoticeSeen(id)` records a notice: the × records
* every notice in the card, a notice's link records that one and closes the card. "See
* what's new" opens the release notes and leaves the card up, because the warning in it
* has not been answered yet.
*
* @param {{text: string, releaseUrl: string, notices: Array<object>, onNoticeSeen: (id: string) => void}} opts
*/
showUpdateNotice({ text, releaseUrl, notices, onNoticeSeen }) {
const seen = (id) => { try { onNoticeSeen?.(id); } catch (err) { console.error('[notice] onNoticeSeen failed:', err); } };
const card = this._noticeCard('security-notice update-notice', (reason) => {
if (reason === 'close') for (const notice of notices) seen(notice.id);
});
const main = this._noticeEl('div', 'security-notice-main');
main.append(this._noticeEl('p', 'update-notice-text', text));
const actions = this._noticeEl('div', 'security-notice-actions');
for (const notice of notices) {
const section = this._noticeEl('section', 'security-notice-section');
const head = this._noticeEl('div', 'security-notice-section-head');
head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice'));
section.append(head, ...this._noticeTextBlock(notice));
main.append(section);
const link = this._noticeLinkButton(notice, card, () => { seen(notice.id); card.close('link'); });
if (link) actions.append(link);
}
actions.append(
this._noticeButton(
'security-notice-secondary',
"See what's new",
card.act(() => window.open(releaseUrl, '_blank', 'noopener,noreferrer'))
)
);
main.append(actions);
card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton);
card.mount();
return { dismiss: () => card.close(null) };
},
/**
* The shell both notice cards share: role=alert, the ×, mount and a once-only close.
* A closing card stays in the DOM for its 200 ms fade, so close() also makes it inert, and
* `act(fn)` wraps a button's handler to do nothing once the card is closing (a double click
* on a link used to open it twice; the × and Dismiss go through close(), already once-only).
*/
_noticeCard(className, onUserClose) {
const el = this._noticeEl('div', className);
el.setAttribute('role', 'alert');
el.setAttribute('aria-live', 'assertive');
let closed = false;
const close = (reason) => {
if (closed) return;
closed = true;
el.inert = true;
el.classList.remove('show');
setTimeout(() => el.remove(), 200);
if (reason && typeof onUserClose === 'function') {
try { onUserClose(reason); } catch (err) { console.error('[notice] onClose failed:', err); }
}
};
const closeButton = this._noticeButton('security-notice-close', '×', () => close('close'));
closeButton.setAttribute('aria-label', 'Close');
const mount = () => {
this._toastStack().appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
};
const act = (fn) => () => { if (!closed) fn(); };
return { el, closeButton, close, mount, act };
},
/** The notice's own title (when it has one) and message: textContent, never translated. */
_noticeTextBlock(notice) {
const parts = [];
if (notice.title) parts.push(this._noticeEl('div', 'security-notice-title', notice.title, true));
parts.push(this._noticeEl('p', 'security-notice-body', notice.message, true));
return parts;
},
/**
* The notice's link as the primary button, or null when the link is not https.
* selectSecurityNotices() already drops such a notice; the URL is checked again here, at the
* sink, because showSecurityNotice/showUpdateNotice take any object. Opened with noopener
* AND noreferrer: noopener alone still sends this instance's origin (a tailnet name, a tunnel
* URL) to the linked site as the Referer.
*/
_noticeLinkButton(notice, card, after) {
const url = notice.url;
if (!window.CodemanSecurityNotices?.urlOk?.(url)) return null;
return this._noticeButton(
'security-notice-primary',
notice.label,
card.act(() => { window.open(url, '_blank', 'noopener,noreferrer'); after(); }),
true
);
},
_noticeButton(className, label, onClick, untranslated = false) {
const btn = this._noticeEl('button', className, label, untranslated);
btn.type = 'button';
btn.onclick = (e) => { e.stopPropagation(); onClick(); };
return btn;
},
_noticeEl(tag, className, text, untranslated = false) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
if (untranslated) el.setAttribute('data-i18n-skip', '');
return el;
},
/** Inline SVG icons (no asset): `shield` (with an exclamation) or `update` (arrow up). */
_noticeIcon(kind, wrapperClass) {
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
for (const [k, v] of Object.entries({
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
})) svg.setAttribute(k, v);
const shapes = kind === 'shield'
? [['path', { d: 'M12 3 4.5 6v5.2c0 4.5 3.1 8.4 7.5 9.8 4.4-1.4 7.5-5.3 7.5-9.8V6L12 3z' }], ['path', { d: 'M12 8.5v4.5' }], ['path', { d: 'M12 16.5h.01' }]]
: [['circle', { cx: '12', cy: '12', r: '9' }], ['path', { d: 'M12 16.5v-9' }], ['path', { d: 'm8.5 11 3.5-3.5 3.5 3.5' }]];
for (const [tag, attrs] of shapes) {
const shape = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) shape.setAttribute(k, v);
svg.appendChild(shape);
}
if (!wrapperClass) return svg;
const wrap = this._noticeEl('span', wrapperClass);
wrap.setAttribute('aria-hidden', 'true');
wrap.appendChild(svg);
return wrap;
},
/**
* A prominent, screen-centred status banner — for the small set of messages that are
* genuinely worth interrupting the eye for rather than living in the corner with every
+12 -2
View File
@@ -597,8 +597,18 @@ Object.assign(CodemanApp.prototype, {
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they are kept as their own on this device and win over the server's on the
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
// role was known) marks the page read-only for the writes after it.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteRefusal?.(res))
.catch(() => {});
},
renderPresetDropdown() {
+21
View File
@@ -0,0 +1,21 @@
{
"notices": [
{
"id": "opencode-console-breach-2026-10",
"severity": "warning",
"cli": "opencode",
"published": "2026-10-17",
"expires": "2026-12-31",
"title": "OpenCode Console breach: check your API keys",
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
"i18n": {
"zh-CN": {
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
"label": "查看 OpenCode 公告"
}
}
}
]
}
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more