Compare commits

...
Author SHA1 Message Date
Codeman maintainer 7064b3c1d5 feat(skill): CODEMAN_WORKER_ADVISOR gives spawned claude workers an advisor
spawn_worker builds its quick-start body itself ({caseName, mode,
parentSessionId}), so an agent driving the skill had no way to give a worker
the advisor without hand-building the call and losing the readiness ladder,
hooks vetting and trust-dialog fallback. Setting CODEMAN_WORKER_ADVISOR
(fable / opus / sonnet) now adds `advisorModel` for every claude worker that
spawn_worker or spawn_workers starts; other modes ignore it.

A refused value fails the spawn with the server's INVALID_INPUT message. A
server without advisor support drops the field silently (the schema is not
strict), so spawn_worker reads it back and says so on stderr.

The preamble changed, so CODEMAN_PREAMBLE is bumped to 1.33.4 and stale
cached copies are rewritten instead of silently ignoring the variable.
SKILL.md's heredoc and the plugin mirror are synced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:05:49 +02:00
Codeman maintainer 01f403dc1b feat(claude): advisor tool support, per session and as an App Settings default
Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's
main model consult a second, stronger model at decision points: before
committing to an approach, on a recurring error, and before declaring a task
done. Codeman can now start claude sessions with one.

- `advisorModel` field on POST /api/sessions, /api/quick-start and
  /api/ralph-loop/start (fable, opus, sonnet or a full model id in those
  families; haiku cannot advise and is refused). Stored on the session and
  persisted, so respawn, boot restore and reboot restore keep it. Remote and
  docker quick-starts refuse it, as they refuse effort.
- App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable),
  synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it.
  Default sends nothing, leaving the CLI's own /advisor choice in charge.
- Carried as the `advisorModel` key in the launch's single --settings JSON,
  merged with ultracode and the statusLine exporter, never the --advisor
  flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead
  pane on every respawn, while the settings key degrades to no advisor. A
  launch without an advisor is byte-identical to before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:05:43 +02:00
Codeman maintainer 9240493c43 chore: version packages (1.33.3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:51:32 +02:00
Codeman maintainer f776ad87b6 chore: changeset for the 1.33.3 landing (#503, #506, #507, #509)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:20:39 +02:00
Codeman maintainer 846c62fbf7 fix(web): bound a pending #session= link and retire it on Home or a web tab (#507 review)
- A #session=<id> link whose session never appears (closed, a typo, or
  another user's session in multi-user mode) is dropped after
  URL_SESSION_WAIT_MS (30 s) with a "Session not found" toast instead of
  waiting forever. One stored timer per link, cleared whenever the link is
  followed, replaced by a newer link, or retired.
- goHome() and opening a web tab now retire a waiting link, so a session
  that turns up later no longer takes the screen. App-made web tab opens
  (frame self-recovery, the fallback after the active web tab closes) pass
  auto: true and keep it, as selectSession() does.
- zh-CN translation for the new toast.
- selectSession's auto: true comment now lists the #session=<id> link.
- docs: the 30 s bound, a win.location.replace() tip that avoids piling up
  history entries, and the fragment declared a stable SemVer surface in
  versioning-policy.md.
- Tests: timeout drops and toasts, an early arrival is still selected, the
  wait does not restart, goHome and a web tab retire it, an auto web tab
  open keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:20:14 +02:00
Codeman maintainer 73c0bfccc4 fix(files): keep attachment markdown refs from resolving into the workspace, and render files without chat line breaks (#503 review)
- A markdown preview opened by attachment id under a bare file name
  (attachment cards, history drawer) no longer resolves relative refs
  against the workspace root: filePreviewText carries attachmentId, and
  the rebase pass turns those images into their alt text and unwraps
  those links. Absolute-path and workspace previews are unchanged.
- _renderMarkdown(text, { breaks = true } = {}): the File Viewer passes
  breaks: false, so a hard-wrapped paragraph renders as one paragraph;
  the Response Viewer keeps a <br> per newline.
- Absolute paths linkified inside a rendered document now carry the
  preview's data-session-id.
- CLAUDE.md, architecture-invariants and the Working-With-Files wiki page
  now say that only an in-workspace path clicked in the terminal keeps
  the tail viewer.
- Tests in test/file-preview-markdown.test.ts for all three fixes,
  including an end-to-end run of the shipping app.js + marked + DOMPurify.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:19:46 +02:00
Codeman maintainer 3af1ff6fae fix(split-pane): keep Pane B's disconnected marker last when the socket closes mid-pull (#506 review)
- terminal-split.js: move the socket's close into _onSocketClosed(), which
  defers the marker while a history pull holds live output (_liveQueue);
  _pullHistory() records closedBefore and its finally writes the marker
  after the queue flush when the socket closed during the pull, replayed
  or not, so it never lands above held frames or between replay chunks
- tests: drive the real close path for a close mid-fetch ending in a skip,
  a downgrade or a failed fetch, a close during the chunked replay, and a
  close with no pull running; pin the onclose wiring in the static guard;
  describe the mid-fetch case on its own
- CLAUDE.md: turn the plain-text split-pane pointer into a link
- architecture-invariants.md: describe the deferred marker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:18:57 +02:00
Codeman maintainer 988f111cd0 fix(ui): keep a focus the row menu moved when closing the Session Manager (#509 review)
- _restoreOverlayFocus(key, modal) now leaves focus alone when something
  outside the overlay already holds it (not <body>, not inside the modal).
  The Session Manager's "Switch to session" and "Open folder" call
  selectSession() before closeSessionManager(), and the restore was pulling
  focus back from the terminal to the header button. Both close methods pass
  their modal; a regression test drives that order.
- Test harness: focusHarness() routes getElementById through a local binding
  instead of leaking globalThis.__els, and its modal stubs report their own
  search box as contained, as the real DOM does.
- CLAUDE.md and docs/architecture-invariants.md: record that the global
  Escape handler calls every close method on every Escape (capture phase),
  so a close method with side effects must return early when not open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:18:56 +02:00
Codeman maintainer 5f5de5827e Merge pull request #503 from JDProfresh/feat/file-viewer-markdown
feat(files): render markdown in the File Viewer, with Lines/Wrap toggles
2026-10-01 11:09:26 +02:00
Codeman maintainer 0aba9f6ec8 Merge pull request #507 from irisitymichaelgrundberg/feat/select-session-from-url
feat(web): select a dashboard session from a #session=<id> link
2026-10-01 11:09:26 +02:00
Codeman maintainer 61dbd97ba4 Merge pull request #506 from timkjr/fix/split-pane-scroll-history
fix(split-pane): let a Shell Pane B's scroll-up reach tmux history
2026-10-01 11:09:26 +02:00
Codeman maintainer b5d8122ea7 Merge pull request #509 from dignfei/fix/overlay-focus-restore
fix(ui): stop Escape from stranding the keyboard after closing an overlay
2026-10-01 11:09:25 +02:00
d fei 57f7a77573 fix(ui): restore focus only when the overlay was actually open
Review feedback. The global Escape handler in app.js calls both
`closeSessionManager()` and `closeCommandPalette()` on every Escape, whether or
not either overlay is open, in the capture phase. Nothing was saved in that
case, so `_restoreOverlayFocus()` fell through to `terminal.focus()` and moved
focus before the focused element's own Escape handler ran:

- split view: with focus in Pane B, keys typed after Escape went to Pane A
- any text field (File Viewer editor, search and history filters, case picker):
  keys typed after Escape went into the terminal
- inline tab rename: the capture-phase focus fired the input's blur (which
  commits) before its own Escape handler (which cancels), so Escape committed
  the rename instead of cancelling it

Both close methods now bail out on `classList.contains('active')`.

Separately, gating the terminal fallback on `activeSessionId` alone only covered
the welcome screen. On a touch device with the keyboard down, focus sits on
`<body>`, so closing the Session Manager focused the terminal and brought the
keyboard up — `selectSession()` deliberately skips that focus, and this
overrode it. It now goes through `_shouldFocusTerminalForTabSwitch()`.

Tests: the Session Manager case's modal stub now uses the harness's
`makeClassList()` (without `contains` the new guard reads it as "not open" and
skips the restore the case is about), plus two new cases — closing either
overlay without opening it first with an active session asserts the terminal was
not focused, which is the path the global Escape chain takes and none of the
five existing cases covered, and a touch device with the keyboard down asserts
the same. Each was checked against the unguarded code: removing either guard
turns exactly its own case red.
2026-09-30 08:35:06 -07:00
d fei af4cc45cfd fix(ui): stop Escape from stranding the keyboard after closing an overlay
Both the Command Palette and the Session Manager call `search.focus()` on
open, and both closed by removing the `active` class and nothing else. Hiding
a focused input does not hand focus back to anyone — the browser drops it on
`<body>` — so after Escape closed the overlay every keystroke went nowhere and
the user had to click the terminal before they could type again.

Measured in headless chromium against a real shell session, one overlay at a
time:

  overlay            activeElement after Esc   can type afterwards
  App Settings       XTERM                     yes
  Session Options    XTERM                     yes
  Token Stats        XTERM                     yes
  Monitor Panel      XTERM                     yes
  Session Manager    BODY                      no    <- fixed here
  Command Palette    BODY                      no    <- fixed here

The four that worked did so because they use `FocusTrap`, whose `deactivate()`
restores focus to whatever held it before. These two never got one. Every close
path has the same hole — Escape, the close method, picking an item — so the
restore lives in the close functions rather than in the global Escape chain.

Deliberately only the save/restore half of `FocusTrap`, not the whole thing:
`FocusTrap.activate()` moves focus to the first focusable element, which in
neither overlay is the search box, so adopting it wholesale would trade "type a
filter the moment it opens" for "focus survives the close" — and the former is
the reason Cmd+K exists. The terminal fallback is gated on there being an
active session: an overlay opened from the welcome screen has no terminal to
return to, and focusing one on a phone summons the on-screen keyboard over a
screen with no input on it.

The five new cases were checked against the unfixed code first: four of them
fail without this change.
2026-09-29 18:02:05 -07:00
Michael GrundbergandClaude Opus 5.5 01eb8ef08a feat(web): select a dashboard session from a #session=<id> link
A page that keeps one Codeman window open, such as a task board, could only
show a session by sending that window to /session/<id>, which loads the whole
app again for every click. The dashboard now reads a #session=<id> fragment
when it loads and on hashchange, selects that session, and removes the
fragment with history.replaceState so the next identical link is still a
change. Re-pointing a window that already shows the dashboard changes only the
fragment, so the page stays loaded and the switch is a tab change.

A link can name a session the dashboard does not list yet, because the page
that created it may link before session:created arrives. The id waits until
that event names it, and picking another tab yourself retires it.

Following a link is an app selection (`auto: true`). The page that set the
fragment may be a script, so it must not spend the session's idle alert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 18:03:55 +02:00
timkjrandClaude Opus 5.5 140ca35e2d fix(split-pane): keep the disconnected marker visible, skip detached sessions
Address Ark0N's review on #506:

- The history pull's own `\x1bc` reset erased the "Pane B disconnected"
  marker onclose wrote, painting a fresh, current-looking history while
  onData kept silently dropping every keystroke on the dead socket — a
  Codeman restart drops the socket while the tmux session (and so the HTTP
  pull) survives, making this easy to hit. onclose now tracks the closure
  via `_wsClosed` in addition to writing the marker (extracted into
  `_writeDisconnectedMarker()`), and a replay re-stamps it in the pull's
  `finally` block, after the live-frame flush, whichever order the close
  and the pull land in.
- `_maybeLoadMoreHistory()` now stands aside for a detached session,
  mirroring `_sendResize()`'s existing check and app.js's
  `_maybeRefetchFullHistory()` — its own window already owns its PTY size
  and scrollback.
- Wording: a non-shell CLI's history is out of scope for this pull, not
  absent (codex and Claude's inline renderer do grow tmux history); the
  alternate-screen skip only matters for a direct-PTY shell, since tmux
  never surfaces the alt buffer to the browser xterm. CLAUDE.md points at
  the invariants heading directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:59:22 -05:00
timkjrandClaude Opus 5.5 17232b01f6 fix(split-pane): let a Shell Pane B's scroll-up reach tmux history
tmux repaints a burst of output instead of scrolling it, so a shell
pane's xterm keeps about one screen of scrollback while tmux holds every
line. The primary pane goes back for it when the wheel reaches the top;
Pane B is a separate xterm that loaded history once at connect and never
again, so after a `cat` its earlier output was unreachable.

Pane B now does the same for a shell session: wheel-up at the top of the
normal screen pulls ?full=1&tail=TERMINAL_TAIL_SIZE and holds the
reader's place across the replay. The wheel listener is capture-phase
because xterm stopPropagation()s the events it consumes.

It follows the primary pane's rules from #494 and its 1.33.2 merge-time
fixes: a window holding no more rows than the pane (which covers a
downgrade), or a pane already at its `scrollback + rows` cap, is skipped
without a rewrite. That skip backs off to 60 s when the window was
truncated or the pane is full, since each ask costs the server a
whole-history capture-pane; an untruncated window keeps the 4 s cooldown.
There is no truncation banner in Pane B, so the 'tail' relabel does not
apply.

Live frames, a {t:'c'} clear included, are held with their arrival time
while the replay runs and applied in order only if they arrived after the
capture. The fetch has a 10 s deadline since it holds live output while
it runs. The tail of _loadBuffer() becomes _endBufferLoad() so the pull
shares its single-flight bookkeeping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:24:08 -05:00
JD 612c69d57a fix(files): decode markdown refs, scope links to the preview session, drop name= from the sanitizer
Review follow-up on #503. marked percent-encodes link and image destinations, and the rebase pass encoded them a second time, so a space or a CJK character in a file name made file-raw look for a file literally named my%20image.png; refs are now decoded once (a malformed escape is kept as written) and stripped of ?query along with #fragment. Root-relative refs resolve from the workspace root as on GitHub instead of falling through as Codeman URLs. Rebased links carry the preview's own session id and the response-viewer delegate prefers it, so a document opened from another session's attachment card opens its links in that workspace rather than the active tab's.

The sanitizer no longer allows name=: marked never emits it, and <img name="app"> made document.app that image, which every inline onclick="app.…()" handler resolves before the global, so one rendered README broke every viewer button until a reload. Adds the zh-CN strings for the three toolbar titles.
2026-09-28 15:22:37 -04:00
Codeman maintainer 848ab48b0a chore: version packages (1.33.2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:35:45 +02:00
Codeman maintainer 0b106b03eb chore: add the cron paste-mode fix to the landing changeset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:25:00 +02:00
Codeman maintainer 54c591c84d Merge origin/master (cron paste-mode Enter fix) into the landing branch 2026-09-28 17:24:52 +02:00
Codeman maintainer 2eece4f8f9 fix(cron): send a paste-mode prompt's Enter as its own write
A cron job in "Paste (direct)" input mode wrote `<text>\r` into the pane
in one piece. Claude Code (measured on 2.1.283) takes a burst of about a
hundred characters as a paste, so the `\r` landed as a newline and the
prompt sat unsent on the composer while the run reported `prompt_sent`.

Delivery now lives in `deliverCronPrompt()`. Paste mode writes the text
raw, waits CRON_PASTE_ENTER_DELAY_MS (300 ms), sends `\r` as a separate
write down the same PTY (so it cannot overtake the text), and arms the
session's composer check through the new public
`Session.verifySubmitted()`, which re-presses Enter while the prompt is
still visibly unsent. A session with nothing to write to now fails the
run instead of reporting the prompt as sent. Typed mode is unchanged.

Verified on an isolated instance: a paste-mode job with a 104-character
prompt submitted on the first Enter and Claude answered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:10:12 +02:00
Codeman maintainer 4d165d3fb1 chore: add the input-delivery fix to the landing changeset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:46:04 +02:00
Codeman maintainer d5ffc22f4a Merge the input-delivery fix from master
fix(input): deliver API prompts through tmux so their Enter is not lost
2026-09-28 16:45:27 +02:00
Codeman maintainer c2dfc775a3 fix(input): deliver API prompts through tmux so their Enter is not lost
A prompt posted to /api/sessions/:id/input without `useMux` was written
into the pane in one piece. Claude Code (measured on 2.1.283) takes a
`<text>\r` burst of about a hundred characters or more as a paste, so the
trailing `\r` landed as a newline in the composer and the prompt sat there
unsent while the route answered 200. A later raw `\r` did not recover it;
a tmux `send-keys Enter` did. Short prompts submitted, which is why it
looked random. The same stranding was seen with Codex and OpenCode.

A plain prompt (printable text plus exactly one trailing `\r`, detected by
`isPlainPromptInput()`) now goes through `writeViaMux` even without
`useMux`: the text is typed, Enter is pressed as its own key, and the
SubmitVerifier re-presses it while the prompt is still on the composer.
The write is awaited, since the browser's POST fallback sends frames one
at a time and a following keystroke must not overtake the Enter. Raw
frames (escape sequences, bracketed paste, a line feed, a bare `\r`) and
an explicit `useMux: false` keep the direct write.

Verified on an isolated instance: the 239- and 104-character prompts that
stranded (at +1 s, at +50 s on ultracode, and on a warm session) all
submitted on the first Enter with no `useMux`. The phone's local-echo
flow (a burst, then its `\r` as a separate write) was measured unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:44:59 +02:00
Codeman maintainer e439cf0ef3 chore: changeset for the 2026-09-28 landing
Folds the #490 and #492 contributor changesets (the latter said minor) into one patch changeset with the Thanks section, one paragraph per change and the fixes applied while landing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:56 +02:00
Codeman maintainer 1f4c390e12 fix(terminal): merge-time fixes for #498
- _logScrollRouting() reports cliMouseTracking, the gate's new input, in both
  the de-dup signature and the console line (xterm's own mouseTracking stays
  'none' for Claude, so it gave no reason for a no).
- Restore two guard tests the new gate made vacuous: the local-scrollback
  opt-out footgun test and the codex/gemini "no version rescues it" fixtures
  now set cliMouseTracking: true, so removing the opt-out or re-adding codex to
  the gate fails again.
- Update the comments and architecture-invariants lines that still described
  the version-only rule (wheel handler header, gate doc, the false paths of
  _maybePageCliTranscript, "holds a tracking mode on continuously").
- Name both fullscreen switches (CLAUDE_CODE_NO_FLICKER=1 and "tui":
  "fullscreen" in ~/.claude/settings.json) in the code comment, the invariants
  and the two wiki pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:40 +02:00
Codeman maintainer 714050fe8a fix(terminal): merge-time fixes for #494
- Skip and latch a bounded Shell window once the browser is at xterm's
  scrollback cap (scrollback + rows): a 1 MiB window of short lines can carry
  more rows than the browser can ever hold, so it replayed and re-captured on
  every scroll-to-top with no 60 s back-off.
- Label a replayed bounded window 'tail' even when the capture was byte-capped,
  so the banner keeps offering Load full history instead of calling the rest
  unrecoverable.
- Pin GET /terminal?full=1&tail=<n> in the route tests: full-history source,
  truncationReason 'tail', and the closing relative cursor move survive the cut.
- Log the bounded skip via _logScrollRouting('repull-skipped-bounded').

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:40 +02:00
Codeman maintainer dfd3df8289 fix(build): merge-time fixes for #500
- pre-push hook: skip with a notice when npm is not on PATH (GUI git
  clients and IDEs often run hooks with a minimal PATH), instead of
  blocking every push on "npm: not found"; real-push test with a
  stripped PATH
- test/git-hooks.test.ts: pin GIT_CONFIG_NOSYSTEM=1 and
  GIT_CONFIG_GLOBAL=/dev/null around the resolveGitHooksDir tests, so
  an exported global or a system core.hooksPath no longer fails them
- watch tsconfig.json, .prettierignore and .editorconfig too:
  typecheck and format:check read them
- check:browser-excludes: fail loudly when the vitest list output and
  the walked test/**/*.test.ts tree share no path (format drift would
  otherwise pass vacuously)
- Reword the PRE_PUSH_MARKER comment: bumping its version would make every
  installed v1 hook read as foreign and never refresh again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:56 +02:00
Codeman maintainer a0fbd1d28d docs(registry): note the cliMouseTracking half of claude's wheel rule (#498)
- claude's declared-for-later wheelForward says the live rule in
  _shouldForwardWheelToApp is the version AND the server-published
  cliMouseTracking flag, so whoever wires the field up needs both

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 272b56d47b fix(session): merge-time fixes for #491
- claude watchingLine: the lookahead keys on "Artifact" alone, so a
  footer truncated mid-chip ("1 Artifact…", "1 Artifact comm…") is still
  refused instead of reporting the shell beside it; comment follows
- test: both truncations return no watching label
- invariants: a chip that waits on a human never counts as watching, and
  the ^ anchor is what stops the retry past the chip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 1645ef5f5c fix(docker): merge-time fixes for #492
- test: the complete-identity case now checks the combined
  agentImageBuildArgPairs() argv on both producers, so the manual
  build-agent-image.mjs path cannot drop the identity unnoticed
- both producers: GIT_IDENTITY_BUILD_ARGS carries the mirror/parity
  warning its gh/az neighbour has
- the partial-identity error names CODEMAN_AGENT_IMAGE_GIT_USER_NAME and
  CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL; test regex follows
- wiki Docker-Cases: mention the identity variables next to the gh/az
  switches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 627b76739c fix(docker): merge-time fixes for #490
- test: every ENV PATH= line in server.Dockerfile must start $PATH:, and
  the ~/.local/bin append is pinned alongside /opt/codeman-cli/bin
- invariants + CLAUDE.md: the append-only PATH rule names ~/.local/bin too
- docker-compose.md: Settings-installed CLIs live in ~/.local on the
  app-data mount; reinstall once after upgrading; hand-run npm installs
  need --prefix ~/.local
- installEnv() JSDoc describes the in-container npm prefix redirect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 83e39c40a1 Merge pull request #504 from Ark0N/fix/phone-tab-strip
fix(mobile): make the phone header tab strip read as live tabs
2026-09-28 16:21:10 +02:00
Codeman maintainer fec0409315 Merge pull request #500 from aakhter/pr/prepush-browser-excludes
build: add a browser-test exclusion check and a pre-push static-check hook
2026-09-28 16:21:09 +02:00
Codeman maintainer b4954c14cd Merge pull request #494 from timkjr/fix/shell-scroll-history
fix(terminal): let a Shell pane's scroll-up reach tmux history

# Conflicts:
#	docs/wiki/The-Dashboard.md
2026-09-28 16:21:08 +02:00
Codeman maintainer c9f47b095a Merge pull request #498 from JDProfresh/fix/claude-inline-scroll
fix(terminal): only forward scroll to Claude while it tracks the mouse
2026-09-28 16:20:57 +02:00
Codeman maintainer 47ac16d6ab Merge pull request #492 from opticon454/feature/static-git-identity
feat(docker): configure static git identity
2026-09-28 16:20:56 +02:00
Codeman maintainer 6d147c1bf1 Merge pull request #490 from opticon454/feature/docker-uv-uvx
fix(docker): keep CLIs installed from Settings across container updates
2026-09-28 16:20:54 +02:00
Codeman maintainer 92921b9107 Merge pull request #491 from irisitymichaelgrundberg/fix/artifact-comment-monitor-needs-you
fix(session): alert for an agent waiting on artifact comments

# Conflicts:
#	src/config/cli-registry/stock.ts
2026-09-28 16:20:52 +02:00
Codeman maintainer 614c7e6cd5 Merge pull request #501 from aakhter/pr/webview-sse-owner
fix(webview): route webview:changed only to its owner in multi-user mode
2026-09-28 16:20:35 +02:00
Codeman maintainer 7659ca8b44 fix(session): keep a tab working while Claude waits for its own workers
When Claude hands work to an ultracode workflow or background agents, it
ends its own turn and closes it with `✻ Waiting for 1 dynamic workflow to
finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when the
workers report back. The pane sits quiet with the composer up, so the idle
probe called the session idle for the whole wait. At phone width the
workflow's progress row also drops its ticking timer, so nothing on screen
changes for minutes.

A new optional registry field, `capabilities.workDetect.awaitingLine`,
names that closing row, and `_probePaneWorking()` counts it as work.
Claude renders the row once from a snapshot and never redraws it, so the
same words stay on screen after the workers finish. `isAwaitingWorkers()`
therefore tests only the newest column-0 row directly above the composer,
never the whole pane and never the PTY stream; a follow-up turn always
puts rows of its own there. The column-0 anchor also keeps an agent from
holding its own tab busy by printing the sentence.

Verified against the live Mac mini pane that reported the bug (2.1.283),
and end to end on an isolated instance: an ultracode session running a
90 s workflow at 46 columns stayed busy through the wait and the
follow-up turn, then went idle 6 s after that turn closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:18:27 +02:00
Codeman maintainer 61037082d1 fix(mobile): make the phone header tab strip read as live tabs
On a phone every inactive tab rendered transparent: grey 11px text
floating in unmarked gaps, a boxed Alt+N digit in each tab (a phone has
no Alt key), names capped at 50px so a shared `w1-` prefix was most of
what showed, and the tab that did not fit was chopped mid-word against
the connection dot. The strip looked like a row of disabled labels.

Phone block of mobile.css only:
- Every header tab is a chip, filled and bordered from the skin's
  --control-* tokens, name in --text at weight 500. Written
  `:where(.header) .session-tab` so it stays at (0,1,0): the per-colour
  left border still wins, and sidebar layout (where the list leaves the
  header) is untouched.
- The Alt+N digit is hidden in the header; inactive tabs drop their
  empty .tab-actions container, which padded the chip's right side.
- Name cap 50px -> 80px, status dot 4px -> 6px, strip gap 2px -> 6px.
- Scroll-driven edge fade: a mask on the strip whose widths follow its
  own inline scroll timeline (registered @property lengths), so the
  clipped tab dissolves into the edge. No JS; a strip that does not
  overflow gets no mask, and browsers without scroll timelines keep the
  old hard edge.

The tap-zone arithmetic comment is updated for the numberless phone
tabs and the bigger dot (the required reserve drops from 38px to 36px;
the 44px min-width stays). test/mobile-tab-strip-chips.test.ts pins the
(0,1,0) selector, the top-level @property registration and the
timeline-after-shorthand order, each of which fails silently otherwise.
test/mobile/tabs.test.ts follows the new name cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:23:22 +02:00
JD 5e27043bf7 feat(files): render markdown in the File Viewer, with Lines/Wrap toggles
Clicking a .md in the Files panel showed wrapped source with an Edit
pencil and no way to see it rendered, although marked + DOMPurify were
already on the page for the Response Viewer. The viewer now renders
.md/.markdown through that same pipeline (one parser, one click
delegate) with an MD pill back to source, and the plain-text view gains
Lines (CSS-counter gutter) and Wrap toggles. All three persist per device
in their own localStorage keys.

- Relative images are rebased onto the workspace-confined file-raw route
  under the document's directory, built inside a <template> so no fetch
  fires before the rewrite; a failed load degrades to alt text. Relative
  links become a.rv-path so the existing delegate opens them in the
  viewer; fragment and http(s) links are untouched.
- The rendered container carries data-i18n-skip so the translator does
  not rewrite the document's prose.
- Markdown fetches the route's 10000-line ceiling; other text keeps 500.
- avif renders inline (file-content image set, file-raw MIME map), and
  avif/ico printed paths open the viewer instead of tailing bytes. .md
  deliberately stays with the tail viewer for printed paths.
2026-09-28 01:49:37 -04:00
Aamer Akhter e71971cab4 fix(webview): route webview:changed only to its owner in multi-user mode
webview:changed carried only {action, id} and the SSE routing hint had no
webview: branch, so every connected client received it: in multi-user mode
any user saw the ids of other users' web-tab creates, edits and deletes.
The event now carries the web tab's owner (from the stored record) and is
routed to that owner plus admins. Single-user delivery is unchanged.
2026-09-26 22:45:35 -04:00
Aamer Akhter e60b5a8a2c build: address review on the pre-push hook and hooks-dir resolution
resolveGitHooksDir now returns a directory only when it is the repo's own
<git-common-dir>/hooks (compared on canonical paths), so a core.hooksPath
elsewhere, global or repo-local, is never written to by postinstall, while a
core.hooksPath pointing back at the repo's own .git/hooks still resolves.

The pre-push hook skips with a one-line notice when a pushed ref is not the
checked-out HEAD (tags peeled) or when git status shows uncommitted or
untracked changes under a path the checks read (src, config, scripts, test,
package.json, package-lock.json, install.sh), since the checks read the
working tree rather than the pushed commit.

Also: honest timing (~10-40s instead of ~15s), CLAUDE.md Session Safety note
on CODEMAN_SKIP_PREPUSH for another session's WIP, 14 (not 9) Playwright
tests, and a note that the browser-excludes check only sees direct imports.
2026-09-26 22:44:14 -04:00
Aamer Akhter 1d85909a06 build: add a browser-test exclusion check and a pre-push static-check hook
npm run check:browser-excludes finds tests that import a browser driver and
asks `vitest list` whether the CI config still collects them; wired into CI.
npm install now also installs a marker-owned pre-push hook that runs the
static CI checks (~15s). Skip with CODEMAN_SKIP_PREPUSH=1; hand-written
hooks are left alone.
2026-09-26 18:45:17 -04:00
JD 1da2fa2529 fix(terminal): only forward scroll to Claude while it tracks the mouse
Claude 2.1.280 renders inline by default: no alt screen, no mouse tracking, transcript in real scrollback. The version-only gate still sent every wheel tick and touch swipe as SGR reports, which Claude ignores, so scrolling a Claude session was dead while codex (routed locally) worked. Gate forwarding on the server-recorded cliMouseTracking flag, which fullscreen mode (CLAUDE_CODE_NO_FLICKER=1) sets.
2026-09-26 15:49:58 -04:00
Codeman maintainer 45ea2e1d32 docs(readme): ask readers to star the project
Adds a centered star call-to-action under the badge row in both the
English and Simplified Chinese READMEs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 04:39:13 +02:00
timkjrandClaude Sonnet 5 f6aa50239f fix(terminal): skip a bounded Shell window before the downgrade guard
A window cut at the tail size can be smaller than the browser's buffer
while tmux still holds more. The downgrade guard reads that as "tmux has
nothing more to give", which is true of an unbounded capture only, so a
bounded window reaching it marked the session exhausted and removed Load
full history from the banner.

The bounded skip now runs first, so such a window never reaches the
exhausted path, and it no longer writes banner state: relabelling it from
the bounded payload would call a terminal holding all of a Load full
history pull "the most recent 1 MiB".

A skipped window that came back truncated cannot reach anything older
than the browser shows, and every ask costs the server a synchronous
capture-pane of the whole history (tail is applied after the capture), so
it puts the session on the 60 s cooldown. An untruncated one keeps 4 s.

_replayWouldShrinkBuffer takes optional pre-estimated rows so a megabyte
capture is not scanned twice. CLAUDE.md's Full-scrollback replay entry no
longer says Shell never pulls on ordinary scroll.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 20:17:22 -05:00
timkjrandClaude Opus 5.5 9676e90133 fix(terminal): let a Shell pane's scroll-up reach tmux history
A burst of output leaves a Shell pane with about one screen of browser
scrollback, because tmux repaints the burst instead of scrolling it,
while tmux itself keeps every line. Shell declined the scroll-to-top
re-pull other modes use, and the Load full history button renders only
once a replay was truncated, so a Shell tab under 1 MiB could not
scroll back at all.

The scroll gesture now pulls ?full=1&tail=TERMINAL_TAIL_SIZE, the same
bound a tab switch loads; the route's existing tail cut marks longer
histories 'tail', so the banner still offers the unbounded pull. A
window no longer than the browser's buffer is not rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:05:16 -05:00
Devvyn bf73a84732 fix(docker): address git identity review 2026-09-25 22:27:26 +08:00
Devvyn 8d358aaa26 feat(docker): configure static git identity 2026-09-25 22:25:11 +08:00
Michael GrundbergandClaude Opus 5.5 a9b48320a3 fix(session): alert for an agent waiting on artifact comments
An agent that publishes an artifact arms a monitor for its comments and
ends its turn. Claude Code shows that on the footer as `1 Artifact
comment monitor`, and #473 put that chip on the list of background work,
so the session counted as watching and its idle prompt opened already
acknowledged. Unlike every other chip on the list, that monitor waits
on the user: the agent hears nothing until somebody comments.

Claude's `watchingLine` now refuses any footer that carries the chip,
through a lookahead over the whole row, so a shell running beside the
monitor cannot report the session as watching either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 07:50:25 +02:00
DevvynandClaude Sonnet 5 95a3b87062 chore: drop changesets already released in 1.33.1
The pnpm and uv/uvx changesets describe work upstream shipped in 1.33.1
(#485, #487), so keeping them would repeat those notes in the next release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-25 11:14:47 +08:00
DevvynandClaude Sonnet 5 8cef31086b fix(docker): persist CLIs installed from Settings across container updates
The image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is image content, so
Update-Codeman.sh discarded every npm-installed CLI (dsh, pi). In the Compose
container, POST /api/clis/:id/install now installs into ~/.local on the
persistent home mount, and ~/.local/bin is appended to the image PATH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-25 08:56:20 +08:00
Devvyn 55790964b7 Merge remote-tracking branch 'upstream/master' into feature/docker-uv-uvx 2026-09-25 08:30:56 +08:00
Codeman maintainer 5ae574374f docs(changelog): add the Thanks section to 1.33.1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:47:08 +02:00
Codeman maintainer 47f209bf0a chore: version packages (1.33.1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:20:42 +02:00
Ark0NandCodeman maintainer d81a4a76de feat(mobile): search box in the Select Case picker (#488)
The phone case picker had no way to narrow a long case list, so finding
one meant scrolling a sheet that showed about six rows at a time.

- A search field filters rows by name (every typed word must match, any
  order, case-insensitive), with a "No matching cases" state. Enter picks
  the case when exactly one row is left; Escape clears, then closes.
- The field is not auto-focused, so opening the picker does not raise the
  keyboard. The list holds its unfiltered height while searching so the
  sheet does not jump, and the input is 16px so iOS Safari does not zoom.
- Layout: the sheet padded the home-indicator inset on top of the footer
  already doing so, leaving a dead band under Create New Case; the sheet
  now grows to 80dvh and the list fills it instead of a separate 50vh cap.
- Opening scrolls the list (its own box, not scrollIntoView) to the
  currently selected case.

Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-24 23:08:53 +02:00
DevvynandClaude Sonnet 5 8841bcc93f feat(cases): refresh the case picker and add search to Manage (#483)
The Run bar's case picker only loaded /api/cases at page load, so folders
deleted or created on disk stayed listed until a reload. It now refetches
on open and every 5 seconds while open, repainting only when the list
changed and falling back to another case if the selected one was removed.

The Manage tab of Add Case gains a search box filtering by name or path.
Reorder arrows are disabled while a filter is active so a swap cannot
involve a hidden case.


Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 23:08:47 +02:00
DevvynandClaude Sonnet 5 77ba41f8da feat(docker): install uv/uvx, libsecret-1-0 and pnpm (#487)
* fix(docker): install pnpm in the Compose server image

`dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run
menu's "DeepSeek - add a terminal profile" button failed with
`dsh: pnpm not found on PATH` (exit 127) on the server image. The agent
image already installs pnpm for the same reason (#352). Pin pnpm@12.6.0
in the runtime-writable CLI prefix and note it in the DeepSeek doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(docker): install uv and uvx in server and agent images

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(docker): install libsecret-1-0 for the Azure DevOps MCP

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(docker): add sudo to the agent image

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(docker): install sudo with passwordless access for the agent user

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* Revert "feat(docker): install sudo with passwordless access for the agent user"

This reverts commit b070c9ee65.

* Revert "feat(docker): add sudo to the agent image"

This reverts commit e98127a804.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 23:08:41 +02:00
Michael GrundbergandClaude Opus 5.5 b80d47aff8 feat(session): close sessions whose agent exited cleanly (#486)
* fix(cleanup): keep .claude-images while a sibling session uses the same dir

cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.

The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.

Split out ahead of the exited-agent sweep for Ark0N/Codeman#446, which closes
sessions unattended and would otherwise make the loss routine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(session): close sessions whose agent exited cleanly (#446)

Part 2 of Ark0N/Codeman#446. Part 1 records an exited agent as
SessionState.paneExit. A session whose agent the user ended with /exit is
now closed through cleanupSession(), the same path the X button takes, so
finished sessions stop piling up on the board. The lifecycle log records
the reason as "agent exited cleanly (status 0)", and the conversation stays
resumable from the Resume list.

shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts
holds the rule. It closes a session only when all of these hold:

- The exit status is an explicit numeric 0 with no signal. An absent status
  is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the
  row stays. A non-zero status or any signal also keeps the row, with the
  exit code on the tab.
- Two authoritative pane reads agreed on that exit.
  TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or
  skipped read neither confirms nor resets the count.
- No start, attach or relaunch is running for the pane.
  Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose
  dead-pane branch revives an exited pane on purpose, and restartCli().

setPaneExit() already scopes paneExit to local mux-backed sessions, so
remote, docker and direct-PTY sessions are never closed.

planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit. That covers an agent that exited just before a reboot, before
the sweep reached it. A crashed agent's record stays eligible, like its row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(web): show "exited" on the phone overview and desktop home rail (#446)

Part 1 of Ark0N/Codeman#446 taught the tab strip and the rich rail rows
to say that a session's agent has exited. The phone overview and the
desktop home rail still said "idle", beside a green or pulsing dot.

_mobileOverviewExit() in mobile-overview.js is now the one rule for all
three surfaces, and _sidebarRichRow() uses it as well. It changes what a
row shows and leaves the row's state alone, because the state still picks
the section and the sort order. An exited row gets an "exited" pill, a
neutral dot and row accent, and a duration measured from when the server
first saw the pane dead. A pending permission prompt or question still
wins, as it does on the tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cleanup): close the gaps review found in the #446 sweep and image guard

Four fixes from a dual review of Ark0N/Codeman#446 part 2.

- The .claude-images guard compares canonical paths, so a sibling that
  reaches the same directory through a symlink keeps it. Its comment used to
  say that case only missed a deletion; it caused one.
- A detached session counts as a live sibling. DELETE ?killMux=false removes
  it from the server's map while its pane keeps running, so the guard now
  reads persisted records too, and exempts only sessions being killed rather
  than every session in cleaningUp.
- A session being closed refuses startInteractive() and startShell(). The
  /interactive route awaits listener setup before the start, and a start
  that raced the close could launch a CLI in a tmux session whose record was
  then deleted. A failed close clears the mark again.
- The clean-exit sweep tries each exit once, keyed by session id and the
  exit's at stamp, so a close that fails is not retried and logged every
  two seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(session): keep a clean exit that lands within 10 s of a pane start (#446)

A CLI that prints a startup error ("not logged in", a bad profile, a config
error) and exits 0 used to lose its tab, and the error with it, about 4 s
after launch. The sweep now keeps any clean exit that lands within
CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch
finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The
row stays as "exited (0)" for the user to read and close.

Verified on an isolated instance: a shell that ran `exit 0` 2 s after start
kept its row, one that exited after 13 s was closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:18:07 +02:00
Codeman maintainer d67da5c9d0 fix(input): an oversized paste no longer poisons the durable input queue (#484)
A single input over MAX_INPUT_LENGTH (64 KiB) was queued for reliable
delivery, refused by both transports (the WebSocket silently, POST with a
400), and never dropped: the client treated the 400 as transient, so the
frame was re-sent every 2 s forever, blocked every later input for that
session, and came back from localStorage on every reload.

- Client: a paste over the frame limit is split into in-limit frames
  (never cutting a surrogate pair) delivered in seq order; over 1 MiB, or
  an oversized mux write, it is refused with a toast and never queued.
- Client: the POST drain drops a frame answered 400/413; a WS error ACK
  drops it too; frames over the limit persisted by an older build are
  pruned on load.
- Server: the WebSocket answers an oversized sequenced frame with
  {t:'ia',seq,err:'too_large',max} instead of silence (an older client
  reads that as a plain ACK and drops it); the POST schema uses
  MAX_INPUT_LENGTH instead of a second 100000 limit.

Verified end to end on an isolated instance: a 110 KB paste reached the
PTY byte-identical over both the WebSocket and the POST path, a poisoned
120 KB persisted frame was pruned on load, and a 2 MB paste showed the
refusal toast with nothing queued.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:17:06 +02:00
Devvyn d6c3386102 Revert "feat(docker): add sudo to the agent image"
This reverts commit e98127a804.
2026-09-24 22:19:20 +08:00
Devvyn 10c263a5b8 Revert "feat(docker): install sudo with passwordless access for the agent user"
This reverts commit b070c9ee65.
2026-09-24 22:19:13 +08:00
DevvynandClaude Sonnet 5 b070c9ee65 feat(docker): install sudo with passwordless access for the agent user
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 22:17:38 +08:00
DevvynandClaude Sonnet 5 e98127a804 feat(docker): add sudo to the agent image
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 22:17:22 +08:00
DevvynandClaude Sonnet 5 3e3a4612e6 feat(docker): install libsecret-1-0 for the Azure DevOps MCP
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 22:03:24 +08:00
DevvynandClaude Sonnet 5 a5283c565d feat(docker): install uv and uvx in server and agent images
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 21:04:18 +08:00
DevvynandClaude Sonnet 5 b46588f247 fix(docker): install pnpm in the Compose server image
`dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run
menu's "DeepSeek - add a terminal profile" button failed with
`dsh: pnpm not found on PATH` (exit 127) on the server image. The agent
image already installs pnpm for the same reason (#352). Pin pnpm@12.6.0
in the runtime-writable CLI prefix and note it in the DeepSeek doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 21:04:06 +08:00
Codeman maintainer e6ddb0485a chore: version packages (1.33.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:57:55 +02:00
Ark0NandClaude 334884e96a feat(models): offer Opus 5.5 in the model picker and task routing (#480)
Adds claude-opus-5-5 to the App Settings model picker (base option with
data-ctx="1" plus its [1m] companion row, since Opus 5.5 has a 1M window)
and to the five task-routing selects, mirroring how Fable 5.1 was added.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 01:57:35 +02:00
69a71287e6 fix(sessions): stop pinning the w1-myapp placeholder as Claude's /resume title (#457)
* fix(sessions): stop pinning the w1-myapp placeholder as Claude's /resume title

Local claude spawns passed the tab name as `--name`. That flag is not only the
cross-session peer name: it is also the prompt-box label, the `/resume` picker
entry and the terminal title, and a pinned title stops Claude generating its own
(`customTitle ?? aiTitle`). So every conversation of a case was listed in
`/resume` as the same `w1-myapp`, and none of them got a generated title. On one
workspace, 34 of 34 conversations spawned with `--name` had no ai-title, while
every conversation spawned without it had one.

Only a name the user chose is pinned now: `Session.cliPinnedName` is the name
when `nameSource === 'manual'`, carried to the builders as a separate `cliName`
so the tab/mux name is untouched. Placeholder and auto names let Claude title
the conversation again.

A rename in Codeman also reaches `/resume`: the new name is appended to the
conversation's transcript as the `custom-title` row `/rename` writes (never
creating the file, never writing an empty title). For a pane spawned without
`--name` this holds immediately; a pane spawned with one re-appends its own
title each turn, so there the new name holds from the next spawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sessions): skip no-op renames and docker sessions when syncing the /resume title

A same-name PUT (the Session Options field saves on blur and recomposes the
unchanged placeholder) no longer flips nameSource to manual or appends a
custom-title row, and docker sessions skip the host transcript scan since their
transcript lives in the container. The skill pages no longer use a w<N>- name
as the peer-name example, and the changeset notes the re-append caveat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: record that nameSource decides --name and renames reach /resume

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: codeman-local <codeman@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:33 +02:00
Julian MartinezandClaude Opus 5.5 7485afecaf fix(session-manager): carry mode, claudeSessionId and resumeId into rows (#477)
_loadSessionManagerList() re-projects each unified item into the
history-record shape _buildHistoryItem renders, and dropped these three
fields. The row's own onActivate still read them from the unified item, but
everything built from the record did not: the ⋯ menu's "Resume session"
relaunched a codex row as claude (no mode, no resumeId), a resumed session
lost its conversation id, and Cmd+K rows showed no mode badge. Same class
of bug as the worktree fields the re-projection already carries (#266).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:29 +02:00
DevvynandClaude Opus 5.5 0a52a99ca9 feat(cli-registry): CLI management write API + Settings UI (Phases 1-6) (#476)
* feat(cli-registry): add cliManagementEnabled flag and GET /api/clis

Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI

Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review).

Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or
custom) via a shallow merge onto its clis.json override; shell/claude are
structurally un-disableable (Decision 4), an unknown id 404s rather than
becoming a creation backdoor.

Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted
install command (shell:true, bounded by timeout, process-group killed on
expiry, output captured, audit-logged). A custom entry's id is refused
outright, independent of anything Phase 5 does (Decision 3: a custom
entry's install text is display-only, never executed).

Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) /
DELETE /api/clis/:id (custom only) — a deliberately minimal request shape
(id/label/shortBadge/binaries/a simple launch variant), assembled into a
full CliEntry with conservative capability defaults and re-validated
through CliEntrySchema before writing, never a relaxed path for
UI-originated entries. Stock-id collisions, duplicate custom ids, and
edits/deletes against a stock id are all rejected explicitly.

Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated
independently on cliManagementEnabled AND admin-in-multi-user-mode
(Decision 5), fetching/rendering GET /api/clis and wiring every write
endpoint above.

Every write endpoint answers the same way when the feature is off: 403
FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own
checklist item). registry-writer.ts is a new, deliberately separate write
module so registry.ts itself stays import-side-effect-free, same tmp+
rename+0600 shape as custom-model-hosts.ts.

27 new/updated route tests covering every gate, collision, and cleanup
path; full CI gate green (415/416 files, 7854 tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else

window.__codemanCliAvailable — the flag isCliAvailable() reads client-side
to gate the welcome-screen buttons, the Run-menu dropdown and the mobile
overview — was built purely from each CLI's own installed-on-PATH resolver
(isClaudeAvailable() etc.), with no reference to the registry's `enabled`
flag at all. So disabling a CLI via the new Settings UI (or a hand-edited
clis.json) updated the settings row and nothing else: every launch surface
kept offering it, both live and after a full page reload, since even a
fresh render never consulted the registry.

Fixed in two places:

- server.ts: after building `available`, intersect the nine real
  SessionMode ids against `enabledClis()`. git/cloudflared (utility
  binaries, not CLI registry entries) and deepseekBinary (a secondary
  installed-only flag for the "add a profile" affordance) are deliberately
  left alone.
- settings-ui.js: `toggleCliEnabled()` now patches
  `window.__codemanCliAvailable` in place and refreshes the welcome screen,
  the mobile overview and an already-open Run menu, mirroring the existing
  `installDeepSeekProfile()` pattern for the same "injected once, needs an
  explicit patch" reason — without this half, the server-side fix alone
  still left every surface stale until the next reload.

New test in test/render-index-html.test.ts: an installed-but-disabled CLI
(codex, forced via clis.json + reloadCliRegistry()) reads as unavailable,
while an installed-and-enabled one (claude) is unaffected by the override.

Verified on the Debian devbox (codeman-devbox, real tmux — this sandbox has
none and WebServer's constructor hard-requires it): typecheck clean, the
new test passes (17/17 in render-index-html.test.ts), the CLI-registry
suites pass (86/86), and the full CI gate is green (415 test files, 7855
tests, 0 failures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap

Phases 1-6 were implemented across two commits (da07b38c, db4557d9) with no
corresponding update to the plan doc itself — every checklist still read
Status: TODO and every box unchecked. Brings the doc in line with the tree:

- A new "Status as of 2026-09-22" section up top: what's actually
  implemented (verified by grepping the routes/schema/UI, not just trusting
  the commit messages), the availability-flag staleness bug found and fixed
  in this session (commit 0c77dd0a) with its devbox verification record, and
  one real outstanding gap.

- The outstanding gap: a custom CLI created via Phase 5's write API has no
  way to actually be launched. The Run menu is static per-mode markup with
  no consumer of window.__codemanCliCatalog, so Phase 6's own "create a
  custom entry, confirm it can be launched" verify step was never actually
  exercised against this. Documented with two candidate fixes, neither
  started.

- Each phase's checklist flipped to [x] where confirmed present in the tree,
  Status lines updated from TODO to DONE, and the two originally-open
  questions (Phase 2's installed source, Phase 5's PUT endpoint shape)
  marked resolved against what actually shipped.

No code changes in this commit — documentation only, so a future session
(or the one already mid-flight on a separate checkout of this same branch)
picks up accurate status instead of a stale plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs: add the CLI-registry deployment plan and the parked Copilot plan

Both were sitting as untracked scratch files in the master checkout,
never committed to any branch. Moving them here rather than leaving them
loose:

- DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up
  series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and
  is where PR C (this branch's own CLI-management work) belongs.
- docs/copilot-integration-plan.md is explicitly PARKED, referenced by
  name in docs/cli-enable-disable-plan.md's own header as a sibling plan
  tracked separately — kept for continuity, not active on this branch.

The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md
and their review-response counterparts) described PR A/B/B2, all now
merged — deleted from the master checkout as stale rather than committed
anywhere, since their content is superseded by the real merged PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* fix(cli-registry): render enabled CLIs in launch surfaces

* test(cli-registry): update frontend branch guard

* fix(test): isolate suite from deployment environment

* fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent

shell/claude were both structurally un-disableable in the original plan
(Decision 4). Revised: shell keeps the hard backend guarantee (it is the
one non-agent mode several code paths assume always exists as a raw-
terminal fallback), but claude is now a normal toggleable entry like any
other CLI.

Safe to do because internal session creation (tmux-manager.ts, session.ts,
Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not
check `enabled` at all - only the Run menu and the HTTP-facing
sessionModeSchema() (new session requests through the normal API) key off
it. Disabling claude therefore behaves identically in kind to disabling
any other CLI: no internal fallback path breaks, it just stops being
offered for new sessions until re-enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): hide shell's toggle entirely instead of greying it out

A permanently-disabled switch next to every other row's working toggle
read as broken rather than intentional. shell now renders no switch at
all - a plain "Always available" label - so there is nothing to click
that could look like it should work but doesn't. Backend guard is
unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this
is UI-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical

renderCliList() previously rendered in registry order (each entry's fixed
order field). Now sorts installed CLIs first, then not-installed, each
group alphabetical by label - matches how a user actually scans the list
(what's ready to use, then what needs installing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* style: prettier fixes from the master merge

* fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels

Four gaps found verifying #476 against the #343 review trail:

- Installed or edited CLIs kept reading as missing/stale. Every binary lookup
  (the nine per-CLI resolvers and the generic registry one) caches in its own
  closure, with a negative-cache backoff of up to 5 minutes, and nothing
  cleared them. invalidateCliExecutableResolvers(binaries) now drops those
  caches per binary; install (success or failure), create, edit and delete
  call it plus invalidateCliResolverCache(id). Before this, a CLI installed
  from Settings could fail to launch for minutes, and an edited custom entry
  kept launching its old binary until a restart.
- The Settings "installed" badge for a custom entry used a private `which`,
  ignoring the entry's searchDirs and the login-shell lookup that spawn and
  the Run menu use; it now asks the same generic resolver they do.
- Install ran on a single click. The #343 review asked for auto-install to
  sit behind an explicit confirm; the confirm now names the exact command,
  which GET /api/clis returns for stock entries only (installCommand).
- The phone Run button showed the two-letter tab badge ("CC", "CX") instead
  of the word ("Claude", "Codex"). It uses the registry label again, which is
  identical to the old static table for every stock CLI (now pinned).

14 new tests; 9 of them fail against the previous head and pass here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs

Must-fix:
- registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that
  does not parse or has group/world permission bits instead of overwriting it
  (isUnsafePermissions now exported from registry.ts)
- mutateRegistryFile(): one promise chain for every mutation, with the
  existence/duplicate checks inside the serialized step, plus a unique tmp
  name per write
- docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings
  section) and api-reference (the six /api/clis routes)
- drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md

Smaller:
- PUT /api/clis/custom/:id keeps the entry's current enabled state when the
  body omits it
- runMode setter falls back to the first enabled catalogue entry, not 'claude'
- shell guard keyed on kind === 'shell' (routes + Settings list); stock probe
  map shared with server.ts via utils/cli-installed-probes.ts
- stock claude label is now 'Claude Code', so the Run menu / phone overview
  label rewrites are gone (doctor row keeps "Claude CLI" via its override)
- welcome buttons are translatable again and read "Run Claude Code" /
  "Run Shell"; zh-CN gains "Run Codex" / "Run OMP"
- install: per-id in-flight guard (409) and CODEMAN_* stripped from its env
- fileoverview / CliEnableSchema comments no longer say stock-only
- test-env isolation changes moved to their own PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* test(cli-registry): pin the #343/#347 findings #476 makes reachable

A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(#347 finding 5: no more `cd <path> && undefined`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 01:48:26 +02:00
Ark0NandClaude Opus 5.5 c46e87fd7a fix(self-update): stalled status and hung shutdown on launchd-daemon installs (#478)
* fix(self-update): stop a stalled status from blocking every later update

A Homebrew node upgrade under a long-running server deletes the versioned
Cellar path the server passes as --node, so every status write from the
updater failed. The update itself still built and restarted (npm and the
build use node from PATH), but update-status.json stayed "queued" forever.
The boot reconcile ran one minute after the restart, inside its 15 min
window, and isInFlight() had no age limit, so "An update is already in
progress." blocked every later update until the next server restart.

- self-update.sh falls back to node on PATH when --node is not executable.
- expireStalledStatus() (pure) fails an in-flight status whose last write
  is older than the stale window; applied on every read (start + status
  poll) and persisted. The live updater heartbeats every few seconds, so a
  running update never trips it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(self-update): a hung graceful shutdown no longer leaves a LaunchDaemon install down

On a KeepAlive LaunchDaemon (headless macOS) the updater restarts by sending
the server SIGTERM and letting launchd respawn it. launchd only respawns once
the process EXITS, and nothing escalates a stuck stop (systemd would SIGKILL
after TimeoutStopSec). Observed after an update to 1.32.1: the server closed
port 3000, server.stop() never resolved, the process stayed alive and the
service stayed down until it was killed by hand.

- cli.ts: the signal handler arms an unref'd 10s timer that force-exits if
  server.stop() hangs.
- self-update.sh (launchd-daemon): wait up to 30s for the server pid to exit,
  then SIGKILL it. tmux sessions live outside the server and survive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-24 01:35:27 +02:00
DevvynandClaude Opus 5.5 dd230b0b6e fix(test): strip every inherited CODEMAN_* var and move quick-start off 3099 (#479)
Split out of #476. A Docker Compose deployment exports CODEMAN_CASES_PATH,
which bypasses the temp HOME, so route tests wrote into the real case root.


Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:35:23 +02:00
github-actions[bot]andClaude Opus 5.5 120d780267 chore: version packages (#474)
* chore: version packages

* docs: sync CLAUDE.md version to 1.32.1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-23 12:34:33 +02:00
Codeman maintainer 0af925fe82 Merge remote-tracking branch 'origin/master' into land/1.32.1
# Conflicts:
#	CLAUDE.md
#	docs/architecture-invariants.md
2026-09-23 12:25:58 +02:00
Codeman maintainer b404dacfde chore: changeset for the merge-time fixes and thanks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:41:11 +02:00
Codeman maintainer cbd1fa639d fix(tmux): merge-time fixes for the exited-agent report (#466)
- docs/wiki/The-Dashboard.md: the tab-appearance table gains the exited
  state (muted dot plus an `exited (137)` badge) and explains the bare
  `exited` variant.
- The detailed sidebar and rail no longer pair the muted dot with an "idle"
  pill: an exited session's pill reads "exited" (neutral styling) and its
  since stamp measures from the observed exit. This is a label override on
  the row model, not a new state, so SESSION_ACTIVITY_RANK and the home
  screen order are untouched, and a pending alert still keeps its own pill.
  The row signature includes the flag so the incremental path repaints it.
- The exited badge is aria-hidden like its sibling badges, and the exit is
  appended to the tab's aria-label in both render paths through one helper.
- test/tmux-manager.test.ts re-adds the junk-trailing-field parser case
  against parsePaneRows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:27 +02:00
Codeman maintainer 43d4be8eeb fix(session): merge-time fixes for the dead-pane resume pin (#467)
- test/setup.ts strips CLAUDE_CONFIG_DIR (pinned in test-env-isolation), so
  transcript-fixture tests such as session-custom-model-restart no longer go
  red on a machine that exports it for a separate Claude account (#255).
- The vanished-tmux-session branch of _setupOrAttachMuxSession() relaunches
  the CLI through createSession() just like a failed respawn, so it now takes
  the same resume pin. A genuinely new session is unaffected.
- After a dead-pane respawn of a fallback-chain CLI, _claudeSessionId names
  the conversation the walk actually pinned instead of the chain tail, which
  the walk may have passed over for lack of a transcript.
- _claudeConfigDir() trims the override like claudeProjectsDir() does.
- The remote-reattach test is labelled as documentation, since the pin
  builder's own remote guard would make it pass either way.
- CLAUDE.md: the create-path pin persists through toState() as
  resumeSessionId, and the end of the walk adds no pin rather than clearing
  the launch seed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:27 +02:00
Codeman maintainer f4d1ee8027 fix(terminal): merge-time fixes for dropped-output recovery (#470)
- The TERMINAL DROP crash-trail line moves behind the scheduler's debounce
  guard, so it is written once per window rather than once per dropped
  frame. At the server's 8ms batching, one second of drops evicted the whole
  50-entry trail, including the recovery lines that explain it.
- A refresh that failed at the capture fetch deadline now returns
  'deadline', and the scheduler does not retry it: that is a stalled link,
  not contention, and each retry was another ?full=1 capture waiting out a
  deadline of up to two minutes. The early-return retries are unchanged.
  CLAUDE.md and the code comments no longer claim every skip reason is
  transient contention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 7a30a31430 fix(terminal): merge-time fixes for the silent-failure paths (#431)
- While another device holds the pane width (_paneWidthRefused), a resize
  now asks for the container's width without applying it locally
  (_geometryForResizeRequest: rows follow the container, columns stay at
  the PTY's). Fitting first re-wrapped the whole buffer to the container
  and back on every 30s mobile retry, and throttledResize ran the
  scrollback clear for a resize that brings no redraw. selectSession
  clears the flag, since it belongs to the previous pane. New unit tests
  run the real mixin against a fake terminal and fail without the fix.
- Session seeds _ptyCols/_ptyRows at spawn (_notePtySpawnGeometry), so a
  reattached pane reports its tmux window's real size through ptyGeometry.
- Session.resize's declined-branch comment names ptyGeometry, not the
  deleted ptyCols/ptyRows getters.
- Delete the dead terminalGeometryAgrees() and its window export.
- test/xterm-private-api.test.ts header: it pins the exact locked version,
  so any bump fails, not only a major.
- The main-terminal fit sweep also matches fitAddon?.fit?.(), and
  CLAUDE.md names the modules it actually covers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer fdfcc15c10 docs(docker): merge-time note for the gh/az sign-in in multi-user mode (#472)
Clone Repo clears the credential helpers for a non-admin, but a non-admin's
Docker case with credential seeding on still receives a copy of the server
account's gh/az sign-in when the agent-image switches are on, the same as
the Claude and Codex credentials. Say so in the multi-user notes so the docs
do not read as a stronger guarantee than they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 697b05b118 fix(docker): merge-time fixes for Update-Codeman.sh (#465)
- Remove exactly the codeman-node-modules/codeman-dist volumes by Compose
  label after a plain `down`, instead of `down --volumes` (which also takes
  any volume an override file declares while the message named two).
  `down --volumes` remains only as a warned fallback when the project name
  cannot be resolved.
- Report a failing first `docker compose config --format json` call with a
  clear error instead of exiting silently under `set -e`.
- Filter empty label lines in the collision guard so an unlabelled container
  cannot hide a real collision; name the moved-checkout exit in its error.
- Comments no longer cite a guard or incident in Start-Codeman.sh that does
  not exist; the README states the real gap (a Node base-image bump leaves
  codeman-node-modules stale because the lockfile did not move).
- docs: Update-Codeman.sh in the docker-self-update.md short-version table
  and a mention in docker-compose.md; "Major updates" moved under "Updating"
  in docker/README.md.
- test: smoke test covers the new sequence, the config failure and the
  empty-line case; quiet stdio; @fileoverview names the fourth concern.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 0462a5d5a0 fix(approvals): merge-time fixes for the watching badge (#473)
- session.ts: a pane capture that fails now CLEARS the watching label
  (and emits watchingChanged so pages drop the badge) instead of keeping
  the last one, so a failed capture degrades toward an alert rather than
  pre-acknowledging the next real idle prompt. Test updated; invariant
  noted in architecture-invariants.
- approvals-ui.js: the header bell counts only unacknowledged items
  (pendingApprovalsCount), matching codeman tui's pendingApprovalCount();
  pinned in watching-no-alert.test.ts.
- mobile-overview.js: move the orphaned "Pill copy per state" JSDoc back
  onto MOBILE_OVERVIEW_PILL_LABEL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer da6fa663e7 fix(terminal): merge-time fixes for the copy gutter strip (#469)
- stock.ts: claude is no longer the only entry declaring transcriptGutter;
  codex declares it too.
- architecture-invariants: the strip applies when the session's CLI declares
  a margin (not detection), and a note that it keys on the session's launch
  mode, not on what is running in the pane (a claude pane dropped to a shell
  still loses up to two columns; copyStripMargin is the escape hatch).
- render-index-html test: the gutter map is injected for a solo
  /session/:id render as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 10f87428c3 fix(cli-registry): merge-time fixes for the run-button accents (#463)
- mobile.css: gemini and antigravity run/gear rules get `!important` like
  pi/omp/grok/deepseek, so the gear half no longer keeps the skin accent
  while the body takes the mode colour (two-tone button on the default skin).
- test/skin-themes.test.ts: static guard that every run mode with a base
  `.btn-toolbar.btn-run.mode-<id>` rule also has a resting rule inside the
  `html:not([data-skin="og"])` block; ids are derived from the stylesheet.
- stock.ts: grok's accent comment names zinc-300 (border/badge colour);
  gemini's accent is #8ab4f8 to match its tab badge and run-mode dot, noted
  as the one exception to the border-colour method.
- types.ts: "(below)" -> "(above)".
- docs/cli-registry.md, CLAUDE.md: `accent` is now measured, not transcribed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 13e652e43f chore: changesets for the 1.32.1 batch
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 2afb1c2c2e docs: trim CLAUDE.md from 265 KB to 142 KB, detail moved to architecture-invariants
CLAUDE.md loads into every session, and its Architecture section had grown
feature write-ups (history, measurements, rationale) that belong in
docs/architecture-invariants.md per the file's own header. Each long block
now keeps what the feature is, where it lives, its setting/default and the
rules that prevent real bugs, and links to its invariants section. Everything
removed was moved there: 29 new sections, extra facts appended to the
existing ones.

Also: hard-coded counts (SSE events, route handlers, module/file counts,
device profiles) replaced by pointers to the source of truth, and the
Debugging commands fixed to use the codeman tmux socket and HTTPS for prod.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:32:34 +02:00
Codeman maintainer 2fb744f865 Merge pull request #470 from rounakdatta/fix/dropped-output-recovery
fix(terminal): recover a dropped output frame, do not merely schedule it
2026-09-23 11:32:14 +02:00
Codeman maintainer de4b1db490 Merge pull request #431 from rounakdatta/feat/mobile-terminal-resilience
fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches
2026-09-23 11:32:14 +02:00
Codeman maintainer 8536aaef7b Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge
feat(approvals): let a session watching its own background work keep quiet (#468)

# Conflicts:
#	src/config/cli-registry/stock.ts
2026-09-23 11:32:11 +02:00
Codeman maintainer 94b093b617 Merge pull request #469 from irisitymichaelgrundberg/feat/copy-dedent-pane-margin
feat(terminal): take the transcript gutter off a copy, at the width the CLI declares
2026-09-23 11:32:02 +02:00
Codeman maintainer 6a01412af9 Merge pull request #466 from irisitymichaelgrundberg/feat/pane-exit-reporting
feat(tmux): report that a pane's agent has exited (#446, part 1)
2026-09-23 11:32:02 +02:00
Codeman maintainer bc04b6457e Merge pull request #467 from irisitymichaelgrundberg/fix/respawn-session-id-collision
fix(session): resume the conversation when respawning a dead pane
2026-09-23 11:32:01 +02:00
Codeman maintainer 5b5e932ec4 Merge pull request #465 from opticon454/chore/docker-major-update-script
chore(docker): add Update-Codeman.sh for scripted major-update rebuilds

# Conflicts:
#	docker/README.md
2026-09-23 11:32:00 +02:00
Codeman maintainer f1dfbcdd65 Merge pull request #472 from opticon454/feature/git-host-auth-clis
feat(docker): opt-in gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos
2026-09-23 11:31:53 +02:00
Codeman maintainer 233af33dac Merge pull request #463 from opticon454/fix/cli-accent-colours
fix(cli-registry): correct accent colours, and a real gemini/antigravity/omp rendering bug
2026-09-23 11:31:52 +02:00
Codeman maintainer 993e5e021c Merge pull request #471 from DodgyBadger/fix/mobile-blank-long-press
fix(mobile): swallow blank-space terminal long presses
2026-09-23 11:31:52 +02:00
DevvynandClaude Opus 5.5 02e40f506b fix(docker): gate gh/az seeding on its switch; no shared git sign-in for non-admin clones
Addresses the review on #472.

- CRED_STORES: `.config/gh` and `.azure` now carry `enabledByEnv`
  (CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ), and resolveDockerCredentialArtifacts
  skips a store unless that variable is exactly `1`, read at container
  create. A host that merely has ~/.config/gh/hosts.yml or a plaintext MSAL
  cache no longer copies them into every case container. Tests: the default
  environment seeds neither even with the files present, and each store
  follows only its own switch.
- Multi-user mode: a non-admin's Clone Repo clone and preflight run with
  `git -c credential.helper=` (GIT_NO_CREDENTIAL_HELPERS, placed before the
  subcommand), so the server account's helpers are never lent to them.
  Verified against a real private repo that it also clears the URL-scoped
  credential.<url>.helper entries, and that public clones still work.
  Tests: the argv in test/git-clone.test.ts, and the route decision
  (non-admin cleared; admin and single-user kept) in
  test/routes/case-clone-credential-helpers.test.ts.
- Docs: recreate the case container to pick up seeds (docker/README.md,
  Docker-Cases wiki, docker-cases.md); the multi-user behaviour in
  docker/README.md and security-architecture.md; "functionally unchanged"
  instead of "unchanged" for an image built with both switches off
  (server.Dockerfile comment, README, changeset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
2026-09-23 14:44:26 +08:00
Michael GrundbergandClaude Opus 5 e558264977 chore: leave the changeset to the maintainer
CONTRIBUTING says releases are handled by the maintainer via changesets after
merge, and every `.changeset/*.md` on master was written by him or by the
release bot — including the ones covering other people's pull requests. The
summary this file carried moves to the pull-request description, where it is
the maintainer's to reuse or rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:28:25 +02:00
Michael GrundbergandClaude Opus 5 9a48c43aa1 docs(watching): a restart is not a gap, and here is the measurement
Claimed after a manual test that a session comes back from a server restart
without its badge until it next produces output. Measured instead of assumed,
and it is wrong: a codex session with a background terminal still running had
its label back within about 20 seconds of the restart, with no input from
anyone. Reconciliation re-attaches the pane, the attach repaint carries the
composer glyph, the idle confirmation arms on it, and the probe re-reads the
label — the ordinary path, doing the ordinary thing.

What produced the false claim was a session whose monitor had simply expired
while it sat there. Its footer carries no chip, so `watching: null` was the
right answer and there was nothing missing to restore.

Recorded at the field and in the invariants, because the shape of this invites
exactly one wrong fix: a polling timer to keep a value fresh that the pane
already refreshes by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:00:16 +02:00
DevvynandClaude Opus 5.5 5cf5a45438 feat(docker): opt-in gh + az CLIs with git credential helpers for private repos
Add Case -> Clone Repo could only reach public repositories in the Docker
deployment. This lets a deployment opt in to the GitHub CLI and the Azure
CLI (+ azure-devops extension) as git credential helpers. Codeman itself
still collects no credentials.

- server.Dockerfile / agent.Dockerfile: CODEMAN_INSTALL_GH /
  CODEMAN_INSTALL_AZ build args (0 or 1, default 0; anything else stops the
  build). Off leaves no apt repository, package, extension, helper script
  or credential entry, so a default build is unchanged. On installs from
  the vendors' apt repositories and configures system gitconfig helpers:
  github.com / gist.github.com -> `gh auth git-credential`, dev.azure.com /
  *.visualstudio.com -> new docker/git-credential-azure-cli (an Entra ID
  token from `az account get-access-token`, or AZURE_DEVOPS_EXT_PAT).
  A helper whose CLI is not signed in prints nothing, so a private clone
  still fails fast.
- The extension lives in AZURE_EXTENSION_DIR outside HOME
  (/opt/codeman-az-extensions, runtime-owned; /opt/az-extensions, gid-0
  group-writable in the agent image).
- Hosts turn them on in docker-compose.override.yml: `build: args:` for the
  server image, `environment:` CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ for the
  agent image. build-agent-image.mjs and the in-app auto-build share one
  env -> ARG table (pinned by the parity test) and pass nothing when unset.
  docker-compose.yaml is untouched; .env.example only gains a comment, so
  the self-updater's environment gate sees no new keys.
- Docker cases seed the gh sign-in (~/.config/gh/hosts.yml, config.yml) and
  the az sign-in files from ~/.azure per file, read-only, like pi/grok.
- The Clone Repo AUTH_REQUIRED message says how to sign the server's git
  in instead of claiming private repositories cannot be cloned.
- Docs: docker/README.md "Private repositories", docker-compose.md,
  docker-cases.md, the Quick-Start / Core-Concepts / Docker-Cases wiki
  pages, security-architecture.md, architecture-invariants.md, changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
2026-09-23 08:57:09 +08:00
DodgyBadger 110c4696ad fix(mobile): swallow blank-space long presses 2026-09-22 17:09:01 +00:00
Michael GrundbergandClaude Opus 5 ac6236b268 fix(terminal): clean a copy once, and reach every pane that copies
Review fixes for #469.

The Ctrl+C branch cleaned the selection to decide whether to copy and then
passed that cleaned string to copyTerminalSelection(), which cleans again. The
trailing trim is a fixed point, so that was safe until this PR; the margin
strip is not, because it takes the lesser of the declared width and the run
every line shares, so a second pass takes up to `margin` columns more. The
branch now gates on the cleaned string and hands the raw one on. Verified in
chromium with a real drag, a real Ctrl+C and a real clipboard read on a live
claude pane: an on-screen `      fix(terminal): trim it` reaches the clipboard
as `    fix(terminal): trim it`, and reverting the branch reproduces the
reported `  fix(terminal): trim it`.

Pane B of a split resolves its own width. `_cliGutterColumns()` and
`_normalisedSelectionRange()` take the session and the terminal to read,
defaulting to the primary pane's, so Pane B looks its own run mode up instead
of keeping a margin Pane A drops on the same keystroke. Verified live with two
claude panes open side by side.

A detached session window (`/session/:id`) receives the gutter map. The
injection sat inside the block that skips the run menu's payloads for a solo
window, so the toggle worked in the main window and did nothing in the popup on
the same device. It needs no availability probe, so it moved below that block
and the solo window still carries none of the payloads it skipped before.

The settings description said the width is measured and named Codex as exempt.
Nothing is measured, and Codex is one of the two panes that are stripped.
docs/wiki/Settings-Reference.md gains the row every Terminal and Input toggle
carries. CLAUDE.md no longer says the clean touches trailing runs "and nothing
else" one sentence before the leading-margin rule, and both it and
docs/architecture-invariants.md record that the strip is not idempotent.

Two round-trip tests run on a mode that declares a gutter, which the existing
copyTerminalSelection cases could not, since they all use the harness default
mode that declares none. The Ctrl+C branch itself is pinned at the source,
because it lives inside initTerminal's attachCustomKeyEventHandler closure over
a real xterm the vm harness cannot build. Both pins fail on the reintroduced
bug. Gate: 7865 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:02:55 +02:00
Rounak DattaandClaude Opus 5 00f022ccf8 fix(terminal): recover a dropped output frame, do not merely schedule it
`_onSessionTerminal` drops an incoming frame once the app-owned render queues
already hold 128KB. That is the right call — the alternative is an unbounded
backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
cursor is muffled text (#464). The drop was only half of it.

The recovery was a fire-and-forget timer: it nulled its own handle and then
called `_onSessionNeedsRefresh()`, which opens with four early returns. Two of
them — a buffer load already in flight, a refresh already owning this session —
are MOST likely to be true during exactly the output burst that caused the
drop. So the recovery was skipped precisely when it was needed, with nothing
left to retry it, and the dropped bytes were never replayed.

`_onSessionNeedsRefresh` reports whether it actually repainted now, and
`_scheduleDroppedOutputRecovery` re-arms while it has not. Bounded by
`DROP_RECOVERY_MAX_ATTEMPTS`, because every reason the refresh can be skipped is
transient contention that clears in seconds and a permanently failing refresh
must not become a loop against the API; giving up at the cap leaves exactly what
the old code left, so the floor is no worse than before. The same 2s debounce
still collapses a burst of drops into one attempt.

This is the principle Ark0N established reviewing #431 for the WebSocket
output-gap marker — only a repaint that actually happened settles the recovery —
applied to the one recovery path that still trusted a timer having fired.

The retry decision is a pure function in constants.js so the gate can reach it,
and the scheduler itself is driven from app.js under a fake clock. The retry
case and the no-retry case only pin the fix AS A PAIR: either alone passes
against something wrong, one against the old fire-and-forget timer and the other
against retrying forever. Checked by reverting app.js to the old shape, where
three of the twelve fail.

Two harness details that would otherwise have made the tests lie. The vm context
baked in the real `setTimeout`, so `vi.useFakeTimers()` could not reach the
scheduler and every case reported zero calls; it delegates lazily now. And
app.js reached `CodemanDroppedOutput` as a bare global, which resolves in a
browser but not in the vm — worth fixing beyond the test, because that call sits
inside a timer where a ReferenceError is swallowed and would take the recovery
with it. It reads through `window.` like terminal-ui.js does with its own
constants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:34:03 +05:30
Michael GrundbergandClaude Opus 5 b13672596f fix(watching): tell the page when the badge goes away
Reported from a manual test: a Codex session went on showing the watching badge
after its background terminal had finished. The server was right and the page
was stale — `Session.watching` changes while the session's status does not, and
nothing broadcast it.

The label is usually SET on the idle transition, which broadcasts anyway, so the
badge always appeared correctly. It CLEARS when the work ends, and a CLI can end
background work without taking a turn: codex repaints its background-terminal
row away and stays idle, so `_confirmIdle()` concludes without emitting `idle`
(that emit is guarded by `wasWorking || isInitialReady`) and no other event
fires. Every open page kept drawing a badge the server had already dropped.

`_readWatching()` now emits `watchingChanged` when, and only when, the label
really changes, and the wiring pushes the session state on it. No new SSE event:
the badge reads off the session payload every surface already has.

A/B measured on an isolated beta with the page loaded and then left untouched.
Without this commit the server dropped the label at t+50s and the page still
showed the badge at t+100s; with it, page and server cleared in the same
ten-second window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:59:45 +02:00
Michael GrundbergandClaude Opus 5 8d45b92eba docs(codex): record that a sub-agent leaves no row to read
Measured on the same beta, codex-cli 0.154.0: a sub-agent started without
waiting outlives the turn exactly as a background terminal does — the sandboxed
process was still running — and codex shows nothing for it. The last rows of the
pane are the composer and the status line, and `Sub-agents running` belongs to
the on-demand `/subagents` panel rather than to the row above the composer.

So there is no second codex label to add. A codex session waiting on a sub-agent
reads as plainly idle, which misfiles nothing (codex raises no idle prompts) and
simply leaves that one kind of quiet unexplained until codex pins a row for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:35:56 +02:00
Michael GrundbergandClaude Opus 5 05c788ce9d fix(watching): close the review findings on the label and its window
A dual review (Codex CLI and Claude's code-reviewer, same diff, same brief)
found the trust boundary weaker than the comments around it claimed. Eleven
findings, all applied.

The two blockers were both about who can write the row the label is read from.
Claude's window covered two rows, and the second one is the status line, whose
command a session running with permissions bypassed can write into its own
`.claude/settings.json` — so an agent could print `· 1 monitor ·` onto a row of
its own and silence its own idle alert. The default window is one row now, which
is the footer and nothing else, and the constant says why. Separately, the label
reached `data-tab-meta-sig` unescaped while the row is installed with innerHTML,
which is an injection sink for any config-supplied pattern whose capture group is
permissive; it goes through escapeHtml() like every other untrusted string in
that file.

The Codex entry could not be fixed the same way, and now says so. Its row is
third from the bottom only while a terminal runs; with none running that slot
holds the last row of the transcript, so matching the complete row (with the
`/stop to close` tail, window narrowed to three) raises the bar without closing
it. What contains it is `hooks: 'none'`: no hook event from a codex session
reaches notePrompt(), so a forged label costs a wrong badge and cannot quiet an
alert. The registry comment, `docs/cli-registry.md` and the test all state that
rather than claiming a guarantee the code does not have.

Also from the review: the TUI header badge no longer counts an acknowledged
item, which was the same gate the classifier fix already went through and was
wrong for human acknowledgement too; the TUI approval card reads the quiet
reason and drops to a new `info` tone instead of asking for a reply; the badge
carries an aria-label, because the phone it was built for has no hover target;
the schema refuses `watchingLines` without a `watchingLine`; and the pattern and
its window are resolved together rather than one memoized and one not.

Documentation moved with it. The mechanism now lives in
`docs/architecture-invariants.md` with CLAUDE.md keeping the rule and a pointer,
`docs/wiki/Notifications-And-Approvals.md` tells users why a session stopped
buzzing, and both that page and the changeset name the limitation neither did
before: a question asked in plain prose is not a dialog, so it is silenced along
with the false alarms while background work runs.

Verified live again after the narrowing, on an isolated beta: a Claude session
reported `1 monitor` and took its idle prompt acknowledged, and a Codex session
reported `1 background terminal` against the full-row anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:11:33 +02:00
Michael GrundbergandClaude Opus 5 9c286eeddf fix(session): persist an exit retraction, and let tests reach the watcher
Ten findings from a two-model review of this branch. Both reviewers cleared the
detection logic itself; everything here is a gap around it.

A route that starts a command in a pane now PERSISTS as well as broadcasts.
`/interactive` and `/shell` did neither before, and the pane-exit watcher cannot
cover for them: its next tick finds `paneExit` already cleared in memory,
reports no change and writes nothing, so `state.json` kept saying the agent had
exited for as long as the session stayed quiet. Nothing reads that record for a
decision yet, which is exactly why it had to be fixed now — part 2 is designed
to read it. The `clearPaneExitForNewPane()` docstring claimed its callers
already persisted; that claim was false for these two, and now says what the
caller owes instead.

The watcher's four guards were unreachable by any test. `refreshPaneExits()`
opened with `if (IS_TEST_MODE) return;`, so the read gate, the in-flight
suppression, the generation counter and the empty-read rule could each be
deleted with the whole suite green. The tmux call moves into `readPaneRows()`,
which a test subclass overrides — the shape `runRemoteReconnectTick` already
uses in this file for the same reason — and the test-mode gate moves with it, so
what a test cannot do is spawn a process rather than exercise the bookkeeping.
Each of the four guards now has a test that fails when it is deleted.

The muted status dot turned out to be a specificity fight on three surfaces, not
two. `.tab-status.error` was not excluded, so a session whose agent exited and
whose PTY-exit breaker then tripped lost its red dot to the mute — the state the
browser answers with a "restart it?" confirm, and a needs-you colour by the same
argument that protects the two alert classes. And mobile.css gives a `busy` dot
a 9px size and a green glow with `!important`, while `status` stays `busy` for a
pane whose agent died mid-turn, so a phone rendered a grey dot still wearing the
green halo beside a badge reading "exited". Both measured against the real
stylesheets, both now excluded, and the CSS test reads mobile.css too instead of
being structurally blind to half the problem.

Six comments said things that were not true. Two named the stats collector as
what replaces a restored reading, which is the opposite of the design. The
interval constant argued that 2000 ms keeps a read inside a tick, when the
5000 ms exec timeout means it cannot — which is why the in-flight guard exists.
`MuxSession.discovered` did not say the flag is permanent, though `saveSessions()`
serializes it. The empty-read docstring claimed a distinction that `|| true`
makes impossible. The invariants doc promised more than its drift test delivers.
And CLAUDE.md had no pointer at all, leaving its two hardest prohibitions
("never set `status: 'error'`", "never null the pid") only in the file it is
meant to route people to.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:24:09 +02:00
Rounak DattaandClaude Opus 5 e1e7dc5bd8 fix(terminal): Ark0N's read of the #464 geometry work
Five items, two of which he could only see by running it, plus six smaller
ones. Taking the two blockers first, because both were wrong in ways the
existing tests could not catch.

**Adopting the PTY's rows put the CLI's input line off-screen.** A phone that
took a desktop's 43 rows into a viewport with room for 18 painted an
`.xterm-screen` far taller than its container; xterm's own viewport then had
nothing to scroll, so the bottom of the frame sat below the container with no
gesture able to reach it. Output visible, typing invisible, for as long as the
desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now:
width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping
the local row count keeps the composer at the bottom of a viewport that
scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane
now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and
the input line is inside the box.

**`capture-geometry-retry.browser.test.ts` failed, and CI could not see it**
because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp —
`getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this
work removes at the source, so it can never hold again at any viewport. The
case survives on its own terms: a pane already drawing at the requested size
must not be replayed. Its premise is now the #464 invariant itself, that the
floored report and the terminal agree, which is a stronger guard because the
clamp coming back fails it here rather than silently restoring the replay loop.
The helper docblock that repeated the old premise is corrected too.

**A session with no pane reported 120x40 and the client adopted it.**
`resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and
nothing seeds them from the spawn geometry, so a dead-pane session still held
the constructor defaults — clicking that tab resized the browser terminal to
120x40 and, on anything narrower, claimed another device owned the pane when
none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route
answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows`
getters are deleted rather than left available to be misused again.

**The 40-column floor clipped the pane with nothing able to reach it.** The
affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm
and the PTY agree throughout, the terminal is simply wider than the box. It
keys on what does not FIT now, MEASURED (`.xterm-screen` against the container,
on the next frame, because the screen takes its width with the render) rather
than derived from cell arithmetic. Measured at 360px: font 24 applies 40
columns and paints 560px, and all 200px of the overhang is reachable.
`.pty-oversized` is renamed `.term-overflows-x`, because after this the old
name describes only one of the two causes.

**"Scroll sideways" did not work on touch for the sessions it targets.**
`touch-action: pan-x` is cancelled before it starts by the `preventDefault()`
`touchstart` calls on every 'content' tap. The terminal's own touchmove handler
pans the container now, with the axis locked once per gesture so a diagonal
cannot pan and scroll at once, and the CSS grants no `touch-action` at all —
handing the browser a pan AS WELL would move the pane twice for one finger on
the taps where that preventDefault does not run. Measured under real touch
dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the
buffer does not move with it, and a vertical swipe still scrolls the scrollback.

Three defects in the above, found while checking it rather than by being told:

- `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is
  true of a container that is not a scroller — a sideways swipe would have
  locked the axis, done nothing, AND suppressed the vertical scroll it should
  have been. Gated on the class as well.
- The notice advised scrolling sideways whenever the PTY was wider, including
  when it still fitted and nothing scrolled. It is gated on measured overflow,
  and on a comparison against the width this container WOULD request rather
  than the one it currently holds — once adopted those are equal, so the second
  question answers itself false while the condition is still true.
- `_syncTerminalOverflowAffordance` could throw out of `document.getElementById`
  before reaching its try block. It runs off every geometry change, so a
  cosmetic affordance could have taken the resize down with it.

The smaller items:

- `docs/architecture-invariants.md` no longer explains the equality guard as a
  clamp signature; it records what the clamp used to do and why it cannot any
  more. Edited by hand — that file is outside the Prettier glob, and letting
  Prettier near it rewrote eleven unrelated emphasis markers.
- `throttledResize`'s HTTP fallback reads the reply. It is the path where a
  declined resize is least likely to be noticed, because no socket means no
  `{"t":"zc"}` frame either.
- The changeset covers the whole release: the geometry work, the queued replay
  clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket
  output-gap reconcile, the build-generated service-worker precache and
  per-build cache key, and the crash-trail hygiene.
- `@xterm/headless` is declared in the root devDependencies instead of being
  reached through workspace hoisting.
- The output-gap marker is cleared after any response arrives, not only when
  the capture was non-empty: a server that answers with an empty capture HAS
  reconciled us, and leaving the marker set refetched on every reconnect.
- `e587d845`'s message claimed a test asserted the failed-load copy against the
  built asset. It did not — that assertion lived in a probe deleted with the
  other scratch scripts, so the claim was false when it was written. There is a
  real test now, and it reads the source rather than `dist/`, because `dist/` is
  not committed and a test that skips when it is absent would pass for the wrong
  reason in CI.

`Session.ptyGeometry` gets behavioural coverage against the real class in
`session-resize-arbitration.test.ts` rather than a source guard, including the
contrast — a pane that does exist still reports, and still follows a resize —
so "always null" would fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:53:46 +05:30
Michael GrundbergandClaude Opus 5 ce80b7a212 feat(terminal): take the transcript gutter off a copy, at the width the CLI declares
Copying a paragraph out of a Claude Code or Codex pane puts that pane's own
two-column transcript gutter on the clipboard, so every pasted line arrives
indented. #451 shipped the trailing half of the copy clean and left the leading
half out, because deriving the width from the selection fires on 73% of ordinary
indented text and cannot tell a margin from content.

The width is DECLARED rather than derived. `capabilities.transcriptGutter` on
the CLI registry is a bounded integer; claude and codex each declare 2, measured
on live panes, and no other stock entry declares any, so a CLI whose transcript
layout nobody has measured is never touched. The server publishes the map as
`window.__codemanTranscriptGutter`, built by filtering `enabledClis()` on the
capability rather than by listing ids, and `_activeCliGutterColumns()` looks the
active session's mode up in it. The copy path reads no terminal buffer at all.

The declared width is a CEILING, not the answer: `clean()` strips the lesser of
it and the run every selected line shares. A block can therefore only shift as a
unit, the structure inside a selection survives by construction, and a selection
reaching column 0 loses nothing. That is what keeps a `git log` body at its own
four-space indent inside an agent's two-column gutter.

Codex was measured separately, because it renders nothing like Claude: it draws
boxes narrower than the pane and pushes its transcript into ordinary scrollback.
On a live 0.154.0 answer its `•`/`›`/`⚠` markers sit in the gutter, prose
continuations sit at 2, and a nested YAML block the model wrote rendered at
2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, 160, 198, 235 and 282 columns
its indents were 0, 2, 4, 6 and 8 at every one, never 1. Copying that YAML out
of a live Codex pane now yields 0/2/4/6: gutter gone, nesting intact.

Two derived versions were built and measured first, and both are recorded in the
code because both looked correct:

- Painted trailing padding — a full-screen TUI writes real spaces across the
  unused part of a row, a shell leaves them never-written for xterm to trim —
  has no false positives and never over-stripped. It is also a function of pane
  WIDTH: the padding exists only while a rendered line stops short of the CLI's
  own layout width, and Claude's prose wraps to fill it. Dragging the same two
  prose rows of one live transcript at five window sizes, the share of padded
  rows ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298 columns, so the
  strip silently did nothing at every ordinary size while a corpus captured
  entirely at 282 columns said it worked.
- Taking the narrowest indent on the rows around the selection fires at every
  width and over-strips about 1% of selections, because a file listing inside
  the transcript can be the narrowest thing on screen.

Measured over 1,392,281 selections — every 1, 2, 3, 5, 10 and 20-row window of
real Claude screens replayed from live PTY streams at 100, 120, 160, 198, 235
and 282 columns — the declared width over-strips none, breaks no relative indent
and alters no text, and serves 100% of the selections whose own indent covers
the gutter. Verified end to end in a browser with a real mouse drag and a real
Ctrl+C: Claude and Codex panes paste flush at 123, 198 and 298 columns, a shell
pane is untouched at every one.

The strip sits behind `copyStripMargin` (App Settings, Selection & clipboard),
per-device and default ON: a display key, absent from the .strict()
SettingsUpdateSchema, read as `!== false` because the desktop branch of
getDefaultSettings() returns {}. The toggle is checked before the map.

Two review findings from #451, handled:

- The mid-row flag governs ONE line now. `range.start.x > 0` excludes only the
  first selected line, the one whose margin the mousedown genuinely cut off, so
  the same three rows no longer produce three different clipboard results.
- The reversed-drag finding does not reproduce on the pinned xterm.
  `getSelectionPosition()` reads `_selectionService.selectionStart`, whose
  getter returns `SelectionModel.finalSelectionStart`, and that swaps the pair
  when `areSelectionValuesReversed()` says so. A real upward mouse drag through
  chromium against xterm 6.0 reports the same range as the downward drag.
  `_normalisedSelectionRange()` keeps the ordering as a guard, because the model
  one layer down exposes the unnormalised fields under the same two names.

Tests: test/terminal-copy-clean.test.ts (64, up from 31), plus the injected
script stripped in test/server-index-title.test.ts. Every guard is pinned:
removing any one of seven reds at least one test, including declaring the wrong
gutter width. Full suite green, 7,861 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:33:50 +02:00
Rounak DattaandClaude Opus 5 e587d84590 fix(terminal): make the failed-load notice fit the narrowest terminal
A third pass in a real browser, at the widths this app actually renders at.

The notice a failed history load writes into the blanked pane was one
70-character sentence. At 430px that exactly filled the line; at 320px it
wrapped and left a lone '.' on a line of its own. The floor this app will
render at is 40 columns — reachable today by raising the font on a phone — so
the notice is three lines now, none over 25 columns, one fact each: what
failed, that the session is still alive, and what to do.

It says RELOAD rather than "reopen the tab" because `selectSession`
early-returns when the session is already active, so clicking the tab you are
already on retries nothing. The earlier wording named no next step at all,
which left a mostly-empty terminal and no way out of it.

CLAUDE.md no longer cites "758px reachable to the right" as evidence: that
figure is a property of the test content, not of the fix, and the file's value
is that a reader can trust a claim without re-deriving it. What is pinned
instead is the invariant that survives any content — the full pane width is
reachable, and removing the class returns scrollLeft to 0, so a resolved
mismatch cannot leave the pane parked off-screen.

Verified at 430, 360 and 320px against the shipped bundle, with the test
asserting the built asset carries the copy so an edit that never reached the
build fails rather than passing on the source's wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:57:40 +05:30
Rounak DattaandClaude Opus 5 d9fa9ba1eb test(terminal): follow the existing suites to the one geometry owner
The gate caught fourteen failures the focused tests could not: every harness
that builds a partial app out of cherry-picked mixin methods, and every source
guard that named `fitAddon.fit()` by hand.

Most are wiring — `syncTerminalGeometry`, `_refitAfterCellSizeChange` and
`_resizeTerminalTo` added to the fakes so the real chain runs rather than a
stub of it. `file-browser-search` is the one that shows why it matters: without
the method on the fake, selectSession's unconditional call threw into its own
catch and every later assertion in the file measured a load that never
happened.

Two are not wiring.

`detached-session-pane-sizing` pinned the behaviour this change deliberately
reverses. It asserted the LOCAL fit still runs for a session owned by its own
window — "withhold the send, never the reflow" — so the assertion is restated
rather than patched, with the reason beside it and in the file's docblock: a
reflow the PTY is never told about leaves this xterm rendering a CLI's frames
against a shape that does not exist, and the popup that owns the pane is
drawing for its own width regardless. The old rule bought a garbled frame, not
a correct one.

`mobile-prompt-composer` sliced `_cleanupSessionData` as a fixed 1200-character
window, so the assertion depended on how much unrelated code sat above the line
it cared about. It reads the whole method now.

`terminal-scroll-intent` records `syncTerminalGeometry` rather than `fit`,
under its own name: recording a bare fit there would name the very thing the
subject was changed to stop doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:22:28 +05:30
Rounak DattaandClaude Opus 5 abf1d1f1ca fix(terminal): the PTY and the browser terminal must never disagree about size
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen".
The screenshot is not a dropped frame or a frozen renderer — it is arithmetic.
Claude Code's TUI wraps its frame at the width the PTY reported and erases the
previous frame by walking the cursor up the rows it believes that frame took. A
browser terminal of a different width makes each logical line occupy more
physical rows than Ink counted, so `eraseLines(n)` clears too few and the new
frame paints over rows nothing erased: doubled lines, and short tool summaries
sitting inside longer prose rows with the prose's tail still visible.

Reproduced against this repo's own xterm before changing anything — a 120-column
PTY against a 62-column terminal renders every wrapped line twice. `test/
terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching
widths beside it, so the assertion cannot be satisfied by code that fixes
nothing.

Four ways the two drifted apart, none of them observable from either end:

1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every
   server-facing path reported those floored at 40x10. Measured in Chrome at
   430px: font size 44 proposed 13 columns, the server was told 40, and xterm
   stayed at 13. Three call sites each did their own fit-then-floor, and two
   re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly
   there, so the container had moved.
2. `throttledResize` (keyboard up) and `sendResize` (session detached into its
   own window) reflowed locally and withheld only the SIGWINCH. That is the one
   combination that cannot be right: a reflow nothing is rendering for buys
   nothing and costs correctness. Both now withhold everything, and the
   keyboard's settle timer still sends the one resize that stops the PTY going
   stale.
3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry
   change — and told the server nothing at all, so raising the font on a phone
   left the CLI wrapping at the old column count.
4. `Session.resize` DECLINES a small-viewport request while a desktop connection
   holds an active sizing claim, and said nothing, because resize was write-only.

`syncTerminalGeometry()` is now the one function that may change the terminal's
size: it fits, floors and applies as a single step, so the numbers xterm holds
are the numbers the server is told. A test sweeps every module for a bare
`fit()` on the main terminal, and finds exactly one — the owner's own.

For (4) the client cannot win, so it is told the truth instead: both transports
answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket,
the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal
that keeps a shape the PTY refused does not render "too narrow", it renders
garbled. Adopting can leave the pane wider than the screen and the container is
`overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as
long as the mismatch lasts: correct-and-reachable beats correct-and-clipped
beats garbled. That rule sets both overflow axes and its own `touch-action`
because mobile.css loads later and sets `.terminal-container { overflow:
visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y
computing to `auto` and hand the browser a vertical scroll container the
terminal's touch handler knows nothing about.

Verified in Chrome at 430px against a live server, with a desktop client holding
the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y:
hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own
vertical scrolling. The pre-fix build was measured in the same harness for the
control.

Two things this deliberately does not do. It does not change who owns the pane
size — the desktop still wins, and `_startMobileResizeRetry` still takes it back
once that goes idle. And `throttledResize` still holds the PTY's shape for the
whole keyboard animation rather than sending a SIGWINCH per step; that decision
predates this and was not re-tested here.

Also in this commit, Ark0N's third-pass review items on #431:

- The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both
  used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes`
  (32MB) — the largest body the frontend asks for anywhere. One carried no
  deadline at all and the other got the 15s tail budget. Both now take the
  full-history budget.
- A `?full=1` capture that outruns its deadline falls back to the bounded tail.
  The pane is blanked before that fetch, so an abort used to leave a black
  rectangle, discard the queued live output and never reach `_connectWs`. A
  failed load now still opens the socket, says one dim line where the content
  would have been, and clears the tab's spinner — which nothing did, so a failed
  select left `aria-busy="true"` set forever.
- `_wsOutputGapSession` is cleared at the repaint that settles it, not in a
  `finally` that also ran on the catch. A reconcile that threw, or hit the new
  deadline — the flaky link the marker exists for — dropped the gap with nothing
  to retry it. `ws.onopen` no longer clears it up front either.
- The replay-clear invariant is pinned in the gate, which is the drift this PR
  exists to fix: `_resetTerminalForReplay` must be a queued write and nothing
  else, and no module may blank the terminal with a `clear()+reset()` pair.
- `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local
  first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was
  never declared, and that is the one function in the app that must not throw.
- panels-ui's two kill-all clears route through the same helper, and the
  xterm-version guard's comment says "resolved lockfile version" rather than
  "dependency RANGE", which is what it has pinned since the last round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:07:33 +05:30
Michael GrundbergandClaude Opus 5 90fd0a5a15 fix(tmux): gate the pane-exit read, and mute the dot on the rich rail too
Four changes the maintainer asked for on Ark0N/Codeman#446 before merging.

The pane-exit watcher stays always-on, but a tick now costs nothing when there
is nothing to observe. `hasObservablePaneSession()` skips the tmux exec while
every session on the manager is one of the shapes `Session.paneExitApplies`
already forces to UNKNOWN: a remote SSH session (its local pane holds the ssh
client), a docker case (a `docker exec` into the container's own tmux), and a
record rebuilt from the socket (no provenance at all). The timer is untouched.
Skipping retracts nothing, for the same reason a failed read does not: the map
still holds the last real reading, and every path that puts a new command in a
pane calls `clearPaneExit()` itself. The two copies of that rule are pinned
against each other in `test/session-pane-exit.test.ts`, because drift between
them is silent in both directions.

`DEFAULT_PANE_EXIT_INTERVAL_MS` was already a constant beside the stats and
remote-reconnect intervals; its comment now says why the watcher owns its own
cadence and why the number is what it is.

The never-default-an-absent-status rule is written where `PaneExit` is declared.
It names `status ?? 0` as the thing never to write, and says that an agent the
OOM killer took would otherwise read as a user typing `/exit` — which is what
absent-stays-absent keeps a later clean-exit sweep away from. Nothing fails when
somebody adds that `??`, which is why the sentence is there rather than a test.

Checking the dot's specificity found a second fight, and it was losing. On the
tab strip the alert rules win as intended: a session that exits with a
permission dialog pending still renders red, and yellow for an idle alert. On
the rich vertical tab rail they did not — that rail's own `tab-state-*` dot
rules are (0,9,1) against the strip's mute at (0,5,0), so an exited session
there kept a full green dot AND the working halo beside a badge reading
"exited". The rail twin matches that specificity exactly and therefore must stay
below those rules in source order; it clears the halo as well, which the strip's
rule never had to think about.

`test/session-pane-exit-ui.test.ts` now resolves the real stylesheet in jsdom
rather than matching selector text: postcss collects every rule that paints
`.tab-status`, a real engine decides, and the tests read back the answer. Two
mutations were run against it to prove it has teeth — dropping the hand-written
alert exclusions fails three cases, and moving the rail twin above the state
rules fails one.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:19:55 +02:00
Michael GrundbergandClaude Opus 5 64c288a683 feat(codex): read Codex's own background-terminal row
Codex states background work too, and it says so in a different place. Claude
writes `· 1 monitor ·` on the last row of the screen; Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its
composer, which puts that row third from the bottom once the status line and
the composer are counted.

So how far up the screen to look is now per-CLI data as well:
`capabilities.workDetect.watchingLines`, bounded to 1..8 by the schema, and
defaulting to Claude's two. That bound is the point. The window is half the
injection guard, since every row it adds is another row the agent itself may be
able to write, and the label is what silences an idle alert. The other half is
the anchor, and Codex's is ` · /ps to view`: chrome naming a slash command only
the CLI can offer, so a session that writes "I left 1 background terminal
running for you" into its own output matches nothing.

Measured against a live codex-cli 0.154.0 pane rather than read out of a
binary. The row appears when the terminal starts, follows the composer down as
the conversation grows, and is gone after `/stop`. Verified end to end on an
isolated beta: the session payload carried `watching: "1 background terminal"`
and the badge rendered with it, and both cleared when the terminal stopped. The
fixtures in the tests are that capture verbatim.

Codex has no hook signals, so no idle prompt and no false NEEDS YOU row: for a
Codex session this is the badge alone, which is the case the maintainer said a
registry field could cover and a hook never could. Cross-CLI tests pin that
neither pattern fires on the other's screen, and that a CLI declaring nothing
still reports nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:58:52 +02:00
Rounak DattaandClaude Opus 5 abd39318e6 fix(terminal): deadline must cover the body, precache must ignore the cache-bust query
Review fixes. Two of these are defects in the previous commit.

1. The fetch deadline only covered time-to-headers. `await fetch()` settles on
   response headers, so clearing the abort timer in a finally around it left the
   body — the multi-megabyte `?full=1` capture the deadline exists for —
   completely unbounded; it only ever bounded a server that accepts a connection
   and never replies. Measured against a server that sends headers immediately
   and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, timer
   cleared there, body completed at 4026ms unaborted. Now the body is read
   inside `_fetchTerminalCapture`, which returns {json, headers, headersAt} —
   headers because two callers read server-timing, headersAt because those same
   callers measure header-vs-body time and can no longer observe that moment.
   `_terminalCaptureInflight` is scoped the same way, so a body still streaming
   counts toward a capture starting beside it. Same test now aborts at 1005ms.

2. The precache could never be hit, and the previous commit made that expensive
   rather than free. `renderIndexHtml` runs `cacheBustAssets`, which appends
   `?v=<mtime>` to every same-origin .js/.css reference INCLUDING content-hashed
   names — confirmed against a running instance:
   `vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101`. `caches.match` is
   query-sensitive, so entries keyed on the bare hashed path were unreachable;
   deriving the list from the manifest turned cheap 404s into ~1.3MB downloaded
   at every install that nothing could read back, once per deploy now that
   CACHE_NAME rotates. The fallback match takes `{ ignoreSearch: true }`, which
   also lets runtime-cached entries survive an mtime change.

3. `_wsOutputGapSession` was only cleared in ws.onopen, so paths that already
   repaint the buffer left it set and the socket replayed everything a second
   time. `selectSession` loads the buffer and only THEN calls `_connectWs`, so
   neither the _isLoadingBuffer nor the _terminalRefreshOwner guard applied.
   `_markTerminalBufferReconciled()` is now called from _onSessionNeedsRefresh's
   finally, from selectSession after its load, and from _cleanupSessionData.

   The scope claim was also wrong and is corrected in the comment: when the
   network drops, SSE drops with it and handleInit's keepTerminal branch already
   reconciles. The genuinely uncovered case is the WS dying while SSE stays up,
   where _onSSETerminal discards SSE terminal frames until _wsReady flips in
   onclose — up to the ping+pong window of output nothing writes.

4. CLAUDE.md said "all of them measured rather than reasoned", which the PR's
   own "not verified" section contradicted. Split explicitly: the replay race is
   measured, the watchdog mechanism is verified against xterm 6.0.0 under jsdom
   (field path resolves, a forced stale handle makes refreshRows a no-op, the
   kick schedules a fresh frame), and the iOS rAF-discard premise is reasoned
   and still wants a device. Adds the two missing entries — the WebSocket
   reconcile and the sw.js/build.mjs "keep these in sync or the build throws"
   contract.

Also: test/xterm-private-api.test.ts pins the RESOLVED lockfile version instead
of the declared `^6.0.0` range, which was the wrong assertion in both directions
— a real upgrade to 6.4.0 can rename a private field while resolving inside the
range, and an innocuous range edit failed while changing nothing installed. And
test/sw-precache-manifest.test.ts now parses HASHABLE out of scripts/build.mjs
rather than hand-copying it, which was the same drift this PR exists to fix; the
parse is guarded against silently matching nothing.

The deadline fix has a behavioural test against a real socket plus a source
guard asserting `await res.json()` precedes the finally — verified to fail when
the helper is reverted to the old shape, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:25:36 +05:30
Rounak DattaandClaude Opus 5 c0422c4e21 feat(terminal): renderer watchdog, atomic replay clear, fetch deadlines, reconnect recovery
Four ways the terminal can silently stop being correct — in each case the
buffer keeps updating, nothing throws, and the only recourse is a reload.

1. Renderer freeze after backgrounding. iOS DISCARDS scheduled rAF callbacks
   when a PWA backgrounds, and xterm's RenderDebouncer only clears its
   `_animationFrame` handle from inside that callback — so one drop leaves it
   permanently set and every later refresh() early-returns. Parsing is
   decoupled from rendering, so bytes keep filling the buffer correctly while
   nothing paints. Codeman has exactly ONE xterm for the whole page load, so a
   single backgrounding wedges it until a reload. Adds a 2s liveness poll and
   `_kickRenderer()`, which does what the dropped `_innerRefresh` would have.

2. Replay clears raced live output. xterm's write() is async-queued while
   reset() is synchronous and, per upstream, "does not clear input buffers and
   does not reset the parser" — so bytes queued before a reset are parsed after
   it and fuse into the snapshot. Verified against the real xterm 6 here:
   write('p8'); reset(); write('rmissions') renders "p8rmissions". The main
   path was already safe via a queued erase; the needsRefresh and clearTerminal
   paths were not. All three now share one queued `\x1bc` (RIS), which unlike
   3J/H/2J also resets modes, charsets, scroll regions and SGR state.

3. Output lost on WebSocket reconnect. Input frames carry seq+cid and are
   delivered exactly once; output frames carry nothing. ws.onopen re-sends dims
   and flushes queued input, and needsRefresh only fires on external-CLI
   startup and SSE backpressure drain — never on reconnect. Output produced
   while offline was simply absent afterwards. Interim fix: reaching onclose
   means the drop was unintentional, so the session is marked and the next open
   reconciles from the server buffer. Sequencing output is the follow-up.

4. Terminal captures had no deadline. No AbortController anywhere in the
   frontend, including `?full=1`, which the code itself calls "unbounded-ish
   work: at the default history limit it can be megabytes". Adds a budget that
   scales with full-vs-tail and with captures in flight, degrading to a plain
   fetch where AbortController is missing.

Also: the service-worker precache was dead — the build content-hashes assets
but sw.js listed pre-hash names, so 15 of 23 entries 404'd (verified against a
running instance) and cache.add().catch() hid it. Offline still worked via
runtime caching, but CACHE_NAME was a constant so activate's cleanup never
deleted anything and every past release's assets accumulated. Both are now
derived from the build manifest. Crash-trail entries are flattened and capped,
since they are joined with \n into one value and one call site interpolates a
server-controlled WS close reason.

The watchdog reads xterm privates — there is no public API. Every access is
optional-chained so a shape change degrades to a no-op. `_renderService` only
exists after open(), which needs a real DOM, so the gate cannot assert the
field path; test/xterm-private-api.test.ts pins the dependency range instead.

Tests: 23 new (terminal-resilience, sw-precache-manifest, xterm-private-api),
all pure/static so they run in the gate, which excludes the mobile suite. One
static source guard in history-truncation-notice updated for the renamed call;
the behaviour it pins is unchanged.

Not verified: no browser available, so no runtime reproduction of the freeze
and no real-device test of the reconnect path. Both warrant a device pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:24:52 +05:30
Michael GrundbergandClaude Opus 5 74884a20eb feat(approvals): let a watching session keep quiet, and fix the tui gate
The badge alone left the row in NEEDS YOU, which is the thing the issue was
about. The fix is the alert that does not fire.

An idle prompt from a session that is watching its own background work now
opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to
`notePrompt()`, which sets `acknowledgedAt` and records why in a new
`acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has
always meant "the alert this prompt armed is spent", and the prompt itself
stays pending, answerable and available as Read My Mind context. A wrong label
therefore costs a card that does not blink, never an alert that was never
created.

Every surface follows from that. The broadcast carries the reason, so a live
page declines to arm the tab alert and raises no desktop notification. The push
is skipped, since a false alarm is hardest to ignore on a phone. A reloading
page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And
`classifySession()` now reads it too, which is a pre-existing bug fixed here:
acknowledging on one device cleared the alert everywhere except `codeman tui`.
It re-arms for free, because the next idle prompt supersedes the item and is
built fresh. Only `idle` is eligible, so a dialog that blocks the agent still
goes red whatever else it started.

The label is pane-derived and therefore prompt-injectable, so it is now read
from the last two rows of the screen only, with Claude's pattern anchored on
the `·` its footer joins items with, ANSI-stripped and length-capped at the
source. An agent that prints `· 1 monitor ·` into its own output finds no
match.

Verified on an isolated beta: a session that armed a monitor took its idle
prompt acknowledged with no alert on any surface, wore the badge, and showed
"quiet, watching 1 monitor" on its still-answerable card; the same session with
the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts`
pins both directions across all four surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:10:05 +02:00
Michael GrundbergandClaude Opus 5 1cb0441bd8 fix(session): degrade the resume pin to the session id, not to nothing
A single pin that failed its transcript gate returned the options untouched,
so `resumeSessionId` fell back to `_resumeSessionId` — undefined for an
ordinary session — and the renderer emitted the bare
`claude --dangerously-skip-permissions --session-id "<this.id>"`. Every
session prompted before its first `/clear` owns a transcript under that id,
so the dropped pin handed back exactly the refusal this branch removes, with
no `||` branch to catch it. It was also a regression against master on the
`restartCli()` path, which pinned `_claudeSessionId ?? this.id` and, since the
constructor seeds that field, could never land unpinned.

The pin now walks three candidates in priority order — the conversation
chain's tail, the launch seed, then the session's own id — and takes the first
one a transcript backs. A candidate that misses is passed over rather than
ending the walk.

Falling off the end pins nothing, which also settles the second half of the
problem: the old code skipped the transcript check whenever the pin was the
session's own id, so a genuinely new pane rendered the two-branch form after
all. That costs a brand-new session claude's "No conversation found" line in
its scrollback, and `wrapWithNice()` prefixes only the first branch of the
rendered `a || b`, so the branch that actually runs loses its priority for the
life of the session. With no transcript anywhere the bare `--session-id` is
the correct command, so the comment claiming an unchanged shape is now true.

The transcript lookup reads the server process's own `CLAUDE_CONFIG_DIR` when
a session declares none. A pane inherits the server environment through tmux,
so on an install that exports it the CLI writes its transcripts there and
every lookup under `~/.claude` was a false negative — which under the old code
meant the colliding command. `claudeCredentialsPath()` and
`realClaudeConfigDir()` resolve the same directory the same way. The header
sentence calling a skipped resume "the safe direction" described the opposite
of what happens at this call site, and says so now.

The create-path fallback writes `_resumeSessionId` alongside the create
options. That branch leaves `isRestored` false, so `_claudeSessionId` is
recomputed from the launch fields and settled on `this.id` while the CLI
resumed the chain tail; the response viewer, Read My Mind and the unified-list
alias map read that field until the next first-hand hook.

Four new tests: a chain tail with no transcript while the session id has one,
no transcript anywhere, the create path's alias, and the process-env lookup.
All four fail against the previous commit. Two existing tests move with the
gate — the guess-refusal test now backs the session's own id, and the
custom-model restart test gives its working pane the transcript that makes
`--session-id` collide in the first place, alongside a new one pinning the
no-transcript case.

CLAUDE.md described the pin as a `restartCli()`-only thing sourced from the
live conversation id. All three halves of that moved here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:21:21 +02:00
Michael GrundbergandClaude Opus 5 3f2cde2db7 feat(session): say when a session is watching its own background work
An agent that arms a monitor, backgrounds a shell or hands a task to a
cloud session is told to end its turn. The pane then falls quiet, Claude
Code's idle_prompt notification arrives a minute later, and every surface
files the session under NEEDS YOU with nothing for a human to answer.

Claude states what it is still running on the last row of its screen
(`⏵⏵ bypass permissions on · 1 monitor · ← for agents`). That row is now
`capabilities.workDetect.watchingLine` in the CLI registry, guarded by
compileVersionRegex() like every other config regex, and the idle probe
reads it off the capture it already takes: `watchingLabel()` in
session-activity.ts searches the last five lines only, so a session that
PRINTS "1 monitor" is not mistaken for one running it.

The label lands on Session.watching and rides toLightDetailedState() out
to every surface. The phone overview, the desktop home rail and the rich
sidebar rows wear it as a `watching` badge in the accent colour, beside
the state pill and never in place of it: an agent can arm a monitor and
ask a question in the same breath, and only the pill says which.

Verified end to end against a throwaway session on an isolated beta
instance: the payload carried `watching: "1 monitor"` once the turn
ended, the badge rendered next to a yellow `waiting` pill, and both
cleared when the monitor died.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:00:30 +02:00
Michael GrundbergandClaude Opus 5 47e7935274 fix(session): resume the conversation when respawning a dead pane
A CLI that launches with `--session-id <id>` refuses an id that is already in
use (claude: `Error: Session ID ... is already in use.`), and every session
whose agent has been prompted owns a transcript under that id. The dead-pane
respawn in `_setupOrAttachMuxSession()` passed the bare launch line, so
recovering such a session relaunched a CLI that died on startup, the pane went
dead again at once, and the conversation was stranded behind a tab that looked
merely idle.

`restartCli()` has pinned a resume id against this since the custom-model work,
and its comment states the assumption that made the other path look safe:
"Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
already has a transcript". A pane whose agent exited has a transcript too.

Both relaunch paths now build options through
`_buildRespawnPaneOptionsWithResumePin()`, and so does the create-path fallback
after a failed respawn, which otherwise met the same refusal that made it the
fallback. Four gates guard the pin, each standing for a way of resuming the
WRONG conversation or of making a working relaunch fail.

A remote or docker session is never pinned. Unlike `restartCli()`, whose route
refuses both, the dead-pane respawn is reached by every session shape. Their
pane commands already render a self-healing `--session-id || --resume`, and
both flip to resume-first once the resume id differs; the conversation lives on
the far side, so a local id resolves to nothing there and the `--session-id`
fallback then collides with the transcript the far side does hold.

The id comes from the conversation CHAIN rather than `_claudeSessionId`, which
also holds history-correlated guesses keyed on the working directory.
`_recordClaudeSessionInChain()` refuses those so they cannot "write a foreign
conversation into this pane's permanent record", and launching from one is
worse than the display bug that rule prevents. The chain tail also outranks the
launch seed, which is written once at construction and never moves off a
`/clear`.

A pin no transcript backs is dropped, because the fallback branch keeps
`--session-id <this.id>` and would collide. A synthetic `restored-<fragment>`
id from socket discovery is dropped too, and logged: it fails claude's `uuid`
token pattern, so the renderer would emit the unpinned command while the caller
believed otherwise.

Tests cover each gate and the rendered command. Four of them fail against the
unfixed source; the remote and docker ones were separately checked against a
build with only that guard removed, since they pass on master for the wrong
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:36:17 +02:00
DevvynandClaude Sonnet 5 a2dcc91ddf fix(docker): add and correct the cross-checkout collision guard for Update-Codeman.sh
Same guard as Start-Codeman.sh's own (docs/docker-self-update.md-adjacent
incident, 2026-09-21): docker-compose.yaml hard-codes `name: codeman`, so a
second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME
Compose project as any other checkout on the host. It has to live here too,
not just in Start-Codeman.sh: this script's own --no-cache build and its
`down`/`down --volumes` both run BEFORE the handoff at the bottom of the
file, so Start-Codeman.sh's copy of the guard would only fire after this
script's own destructive calls already ran — and its default `down
--volumes` is more destructive than Start-Codeman.sh's own targeted
refresh, clearing every named volume the resolved project has.

Also fixes a real bug the same guard shipped with: under `set -o pipefail`,
`grep -v` legitimately exits 1 when nothing survives the filter (the
ordinary, no-collision case), and without `|| true` on the pipeline that
non-zero status propagates through the command substitution and `set -e`
aborts the WHOLE script at the guard — every time, collision or not. Caught
only by actually executing the guard end-to-end against a stub `docker`
(the existing smoke-test harness), never by a static text/regex check on
the source; the stub's `config --format json` response was also fixed to
pretty-print like real Compose does, since a compact one-liner silently
resolved project_name to empty and exercised neither script's guard the
way production output does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-21 21:23:39 +08:00
Michael GrundbergandClaude Opus 5 c67c130caa feat(web): mark a session tab whose agent has exited
The tab now reads "exited (137)" beside the session name, drawn from the
`paneExit` field the server publishes. `applyPaneExitBadge()` owns the DOM
work, called from the incremental render path — the only path a live session
ever takes, since going from live to exited adds and removes no tab and so
never reaches the full rebuild.

An unknown answer draws nothing. A death tmux could not explain reads "exited"
with no number rather than "exited (0)", so an unexplained death and a clean
exit do not look alike. A signal death reads "exited (signal 9)".

The badge carries `data-i18n-skip`, like the status pills: it is generated
text, `i18n.js` walks inserted content, and a dictionary entry added later
would fight the renderer, whose in-place comparison is against English.

The tab also carries a `tab-agent-exited` class that mutes the status dot. That
dot is drawn from `status`, which stays `idle` or `busy` for an exited pane as
the issue requires, so without this a green or pulsing dot sits beside a badge
saying the agent is gone — the first thing a tester asked about. `status`
itself is untouched, so this is a rendering rule only. The CSS excludes the two
alert classes by hand, following the convention the rich-rail dot rules
document: a dot turning red or yellow because a session is blocked on a human
outranks "the agent exited".

The tab keeps its click behavior. X still closes it, and nothing here closes,
sweeps or restarts anything.

`docs/architecture-invariants.md` gains the mechanism under "Session data and
lifecycle", where every comparable one already lives: what the tri-state means,
the four shapes it is absent for, why the watcher cannot ride the stats
collector, why an absent `#{pane_dead_status}` is not 0, and the three things
that must never happen to an exited pane.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:52:15 +02:00
Michael GrundbergandClaude Opus 5 90a95f562b feat(session): publish and persist a local pane's agent exit
The mux layer now knows a pane's agent has exited. This puts it on the session
record, where the board and, later, the reboot restore can see it.

`SessionState.paneExit` carries `{ status?, signal?, at }` and rides the
existing `session:updated` broadcast through `toState()`. No new SSE event. The
server pulls each answer from `mux.getPaneExit()` rather than off a broadcast
payload, so the raw reading never reaches a browser: for a remote or docker
session that reading is the death of an ssh client or a `docker exec`, not of
the agent.

The field is tri-state, and the third state is its absence: `undefined` means
Codeman does not know, and it never reads as alive. `Session.setPaneExit()`
forces that unknown for every shape a dead local pane does not describe. A
direct-PTY session owns no pane. A remote SSH session's local pane holds the
ssh client, whose death means a transport drop OR an exit, which is the
ambiguity PR #355 settled by not guessing. A docker case's local pane holds a
`docker exec` into the container's own tmux. And a session rebuilt from the
socket has no provenance at all: `reconcileSessions()` gives it a synthetic
`restored-<fragment>` id that matches no `state.json` entry, so a remote
session rediscovered after `mux-sessions.json` was lost arrives with no
`remote` field and looks local — `MuxSession.discovered` marks it, and absent
metadata there counts as unproven rather than as proof. The scoping lives on
`Session` rather than in `TmuxManager` so there is one copy of the rule.

`status` and `pid` are untouched. `status: 'error'` belongs to the PTY-exit
circuit breaker and makes the browser offer a restart, and a null `pid` is what
makes the browser re-attach and launch a fresh CLI. A reading that repeats the
previous answer writes nothing and broadcasts nothing.

An unknown answer never reads as alive, but a stale KNOWN one would keep
reading as exited, so `clearPaneExitForNewPane()` retracts it on every path
that puts a new command in the pane: the start/attach path, the `restartCli()`
relaunch behind a custom-model switch, and the remote reattach. Without the
second of those, switching an endpoint on an exited session launched a new
command and then persisted and broadcast the old exit straight back onto it.

`toState()` is also what `state.json` persists, so the record survives a
reboot, which is the only thing that does: a reboot takes the tmux server, and
with it every live signal and every `mux-sessions.json` entry. Nothing reads it
there yet — making the restore refuse such a session is a behavior change that
belongs with the part that closes them. Recovery threads the saved value back
through the constructor so the first persist after boot cannot blank it.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:01:09 +02:00
Michael GrundbergandClaude Opus 5 02dc46dcd7 feat(tmux): report a dead pane's exit from the batched pane list
Codeman creates every tmux pane with `remain-on-exit on`. When the agent exits,
tmux keeps the pane, the tmux session, and the `tmux attach-session` process
Codeman records as the session's pid, so no PTY exit handler fires and nothing
writes the exit down. tmux itself knows: it marks the pane dead and reports the
exit status. This reads that.

`PANE_LIST_FORMAT` gains `#{pane_dead}`, `#{pane_dead_status}` and
`#{pane_dead_signal}`, and `startPaneExitWatcher()` refreshes a
muxName-to-observation map from ONE batched `tmux list-panes -a` per tick. Boot
reconciliation already ran that same call, so it now fills the map too and
recovery starts with a reading.

The watcher owns its own interval rather than riding `startStatsCollection()`,
which the issue suggested. That collector is armed when a browser opens the
Monitor panel and DISARMED when it closes it, and boot skips it entirely unless
recovery found a live session, so a session created on a freshly booted server
would publish nothing and one browser could turn detection off for every other.
Measured on an isolated instance: a dead pane with status 0 reported nothing
until `POST /api/mux-sessions/stats/start` was called by hand. It is still one
batched read per tick; only the timer changed.

Three rules keep a positive answer trustworthy. A session answers only when
tmux listed exactly one pane for it, because Codeman never splits a pane and a
session the user split by hand has none that speaks for the agent. A pane
answers only when `#{pane_dead}` said 1 or 0, because an empty field is a tmux
that did not answer. An absent status stays absent rather than becoming 0:
measured on tmux 3.2a, a SIGKILLed pane reports neither a status nor a signal,
and calling that a clean exit would be wrong in the direction that matters.

Two guards stop a slow read undoing a fast one. `EXEC_TIMEOUT_MS` is 5000 ms
against a 2000 ms interval, so a read can outlive two ticks: one already in
flight suppresses the next, and a generation counter that every
`clearPaneExit()` bumps discards a read that started before a respawn or a
kill. An observation also carries its pane pid, so a second command in the same
pane that exits the same way starts a new timestamp rather than inheriting the
first death's.

A non-empty read of `list-panes -a` is authoritative for the whole socket, so
sessions missing from it are pruned, which also bounds the map as tmux sessions
come and go outside `killSession()`. A failed or empty read retracts nothing.

The manager reports the raw pane reading and applies no session-shape scoping,
because the remote-reconnect watcher beside it needs exactly that raw reading.

`parsePaneList` becomes `parsePaneRows`, returning one row per pane instead of
a name-to-pid map; reconciliation builds its map from the rows. The parser's
existing cases carry over unchanged, including the launchd/systemd literal-tab
regression from PR #71.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:00:54 +02:00
DevvynandClaude Sonnet 5 5b4878df3b fix(docker): address Ark0N's PR review on Update-Codeman.sh — fix the handoff, the build/down ordering, and default-clear the build volumes
Three blockers, all fixed and verified by actually running the script (not
just string-matching it):

1. `exec "$script_dir/Start-Codeman.sh"` failed EACCES/exit 126 on every
   checkout, since Start-Codeman.sh is committed non-executable (100644) —
   the same fact my own second commit on this branch established. Fixed to
   `exec bash "$script_dir/Start-Codeman.sh"`.

2. `down` ran before `build --no-cache`, so Codeman and every session it was
   running were offline for the entire rebuild, and a build failure left the
   stack down with nothing to bring it back — the exact ordering mistake
   Start-Codeman.sh's own "Build BEFORE taking the stack down" comment exists
   to prevent. Reordered to build, then down, then hand off.

3. The default path could throw the rebuild away: codeman-node-modules/
   codeman-dist only re-seed from the image while EMPTY, Start-Codeman.sh
   only clears them when it detects the checkout's HEAD or package-lock.json
   moved, and neither condition is true for the Dockerfile-only change this
   script exists for — so a plain `bash docker/Update-Codeman.sh` rebuilt an
   image whose fresh node_modules/dist then sat unused behind the old
   volumes. Made clearing them the default; `--keep-volumes` opts out
   (replaces the old `--volumes`/`-v` flag, which is no longer needed since
   clearing is now the default).

Smaller items from the same review, also fixed:

- The --no-cache build now derives PUID/PGID from CODEMAN_APPDATA_PATH's
  owner first, via the identical owner_of() helper Start-Codeman.sh uses
  (parity-tested) — without it, the build used Compose's default 1000:1000
  regardless of the real appdata owner (99:100 on the Unraid layout
  docker/README.md documents), and Start-Codeman.sh's own correctly-PUID'd
  build during the handoff would then rebuild those layers anyway, so the
  --no-cache image never actually shipped.
- docker/README.md's "rebuilds ... only when it detects ... moved" wrongly
  described BOTH the rebuild and the volume-clearing as conditional;
  Start-Codeman.sh rebuilds on every start, only the volume-clearing is
  conditional. Corrected, and reworded around the new default.
- --help/-h now prints usage and exits 0 instead of falling into the
  unrecognised-argument branch.
- "the ONLY named volumes this stack declares" now says docker-compose.yaml
  specifically, since a docker-compose.override.yml could add more.

New tests: PUID/PGID derivation parity with Start-Codeman.sh's owner_of(),
--help handling, and — the one that actually catches blocker #1, which five
source-string-matching tests did not — a real end-to-end smoke test: a
synthetic deployment, a stub `docker` on PATH logging every invocation, the
real script executed via a real subprocess. Confirms the real command
sequence (build --no-cache, then down --volumes or plain down, then evidence
the handoff genuinely ran Start-Codeman.sh) and that a working handoff fails
honestly at Start-Codeman.sh's own later check rather than with EACCES.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 15:19:00 +08:00
DevvynandClaude Sonnet 5 3b714446b4 fix(docker): drop the wrong executable-bit assertion for Update-Codeman.sh
Start-Codeman.sh, its sibling and the script it hands off to, is itself
committed non-executable (100644) upstream — it's documented and invoked
as `bash docker/Start-Codeman.sh`, never `./docker/Start-Codeman.sh`. The
"is executable" test I'd added for Update-Codeman.sh asserted the opposite
convention, which the file correctly does not follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 13:58:45 +08:00
DevvynandClaude Sonnet 5 9ba90a674a chore(docker): add Update-Codeman.sh for scripted major-update rebuilds
docker/README.md and docs/docker-self-update.md both already point operators
at "stop the stack, rebuild, restart" for anything the in-app updater refuses
to apply (a changed server.Dockerfile, a changed docker-compose.yaml, or a
new required .env key) — but that was a manual, hand-typed procedure with no
script of its own, unlike every other start/update path this deployment has.

docker/Update-Codeman.sh scripts it: `docker compose down`, then an
unconditional `docker compose build --no-cache` (a major update should be
certain of what actually ships, not reuse whatever layers happened to be
cached), then hands off to the existing Start-Codeman.sh for the same
careful PUID/PGID, override-file and fingerprint handling every other start
already goes through — rather than reimplementing any of that by hand and
risking it drifting out of step.

An optional --volumes/-v flag also removes the codeman-node-modules/
codeman-dist named volumes, the scripted form of the "Resetting the build
artefacts" procedure docs/docker-self-update.md already documents by hand.
Safe: those two are the only named volumes this stack declares; application
data and case workspaces are host bind mounts, never touched by
`docker compose down` either way.

Docs updated: a "Major updates" section in docker/README.md, and a pointer
from docs/docker-self-update.md's existing "Resetting the build artefacts"
troubleshooting entry.

Tests: extended test/docker-entrypoint.test.ts (the existing home for
Start-Codeman.sh's own static checks) with a bash -n parse check, the
down-before-build-before-handoff ordering, the --volumes flag's effect,
unrecognised-argument handling, and byte-for-byte agreement with
Start-Codeman.sh's own override-file resolution logic (so `down` here and
`up` there can never target different Compose files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 13:57:54 +08:00
DevvynandClaude Sonnet 5 d3ee9f23c2 fix(cli-registry): correct accent colours, and a real gemini/antigravity/omp rendering bug
Two related fixes, found while re-measuring stock.ts's `accent` field
against the actual rendered UI (docs/cli-registry.md flags this field as
"transcribed, not authoritative — re-measure before wiring one up"):

1. A real, user-visible bug: `.btn-toolbar.btn-run.mode-gemini`,
   `.mode-antigravity` and `.mode-omp` had no override rule inside the
   `html:not([data-skin="og"])` block, unlike codex/pi/grok/deepseek, which
   do. The generic `.btn-toolbar.btn-run` rule in that block resolves at
   higher specificity than the base sheet's per-mode pair, so all three
   rendered as plain claude-blue on every skin except `og` — including
   `daylight-blue`, which is the actual DEFAULT skin for a fresh install
   (index.html's pre-paint script), not an edge case. Added the three
   missing rules, sourced from each CLI's own already-designed og-skin
   colours (no new colours invented), mirroring the exact pattern
   pi/grok/deepseek already use. Also corrected the stale comment on the
   pi rule, which claimed this was still broken for gemini/antigravity.

2. `stock.ts`'s `accent` field was simply wrong for most CLIs — e.g. claude
   was registered as Anthropic's brand orange (#d97757) while its button
   renders blue, antigravity was registered purple while it renders cyan,
   pi was registered green while it renders pink. Measured each CLI's real
   `border-color` from its own `.mode-<id>` rule on the og skin (the
   cleanest single representative hex each entry's gradient resolves
   around) and corrected all 9 non-shell entries to match. `accent` has no
   reader yet (confirmed via the DECLARED_FOR_LATER guard test), so this
   changes no rendered output — it's a data-accuracy fix, matching the
   registry's own "transcribed, not authoritative" warning taken literally.
   Also fixed a false claim in types.ts's doc comment for the field
   ("CSS derives every per-CLI gradient from it via --cli-accent") — no
   such CSS variable exists anywhere in the codebase.

Full gate: 406 files / 7721 tests / 0 failures, typecheck/lint/format:check/
check:public-assets/check:frontend-syntax all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-21 12:23:19 +08:00
github-actions[bot]andClaude Fable 5.1 9466acfc1a chore: version packages (#461)
* chore: version packages

* chore: sync CLAUDE.md version to 1.32.0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-21 06:00:49 +02:00
Codeman maintainer e899af4305 chore: changeset for the merge-time fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:53:19 +02:00
Codeman maintainer 299a21d5f5 fix(split-pane): merge-time fixes for split-pane sessions (#453)
The maintainer's promised merge-time fixes from the final review of #453:

1. closeSplitPane() tears down a divider drag still in progress, so a split
   that collapses mid-drag no longer leaves body.split-pane-resizing (the
   page-wide col-resize cursor and user-select lock) set until a reload.
2. openSplitPane() re-applies the picker's own exclusions (detached session,
   pid === null, no session record) for a row that went stale while the
   menu sat open, refusing silently like its neighbouring gates.
3. architecture-invariants: the hard-hide of .btn-split is the
   @media (max-width: 1179px) rule in styles.css, not mobile.css.
4. SplitTerminalPane.destroy() nulls onclose (and onerror) beside onopen
   and onmessage.
5. Picker rows drop the data-session-id attribute nothing read.
6. The Pane-A-ends branch collapses with skipPrimaryResize, so the closing
   resize is no longer aimed at the session the server just removed.
7. The {t:'r'} refresh path is single-flight across the fetch and the
   chunked write, coalescing a mid-replay refresh into one trailing re-run.

Tests: split-pane-auto-collapse-unit gains the drag-teardown, exclusion and
skip-resize cases; the new split-pane-terminal-unit covers destroy() and the
refresh single-flight. All were run against the pre-fix module to confirm
they fail there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit dbd39aed015ae5ae5870aba398bf4b4ab5118e47)
2026-09-21 04:53:19 +02:00
Codeman maintainer d8e85285c9 fix(mobile): merge-time fixes for the prompt composer (#444)
- styles.css: restate the composer overlay's own bottom gutter after the fold rules
  (the generic .paste-overlay longhand erased it: 0px flat, hinge strip replacing it
  folded) and subtract the fold strip from the dialog's max-height
- test/foldable-layout.test.ts: simulate the cascade for
  .paste-overlay.prompt-composer-overlay (fails without the CSS fix); pin the palette
  anchor by name instead of ELEMENTS.at(-1)
- keyboard-accessory.js: guard the app global in refreshForActiveSession() like the
  rest of the file
- keyboard-accessory.js: a whitespace-only draft is empty (Send no longer submits
  blank lines); the text still goes out untrimmed
- keyboard-accessory.js: derive _composerMaxLength and the frame refusal from one
  64 KiB frame limit minus both bracketed-paste markers so they cannot drift
- keyboard-accessory.js: translate the textarea placeholder and label at build time,
  since the DOM translator skips <textarea> subtrees
- i18n.js: zh-CN entries for the composer dialog copy
- docs/wiki/Mobile-Guide.md: describe the Compose key instead of a clipboard key
- CLAUDE.md: a "Mobile prompt composer" paragraph after the accessory bar one
- test/mobile-prompt-composer.test.ts: pin the whitespace rule and the derived budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f6725ba52da17b0bdbee8be3b5011e7cae514f69)
2026-09-21 04:39:28 +02:00
Codeman maintainer 0f955327b2 fix(cli-registry): merge-time fixes for the run-menu consolidation (#458)
- test/opencode-resize.test.ts: retarget the launcher guard at the real code (this.selectSession(firstSessionId), any this.activeSessionId assignment) with an anti-vacuity check; the old strings existed nowhere, so it could never fail
- session-ui.js: restore as comments the two invariants the merged bodies lost (deepseek leaves statusReporting unset, i.e. ON; no effort field for external CLIs, it is Claude-specific)
- docs/cli-registry.md: move the frontend-guard paragraph below the two backend-guard paragraphs so they keep their antecedent, and note the widened comparison shape
- test/frontend-cli-no-id-branching.test.ts: the comparison shape accepts any left-hand identifier (const m = this._runMode; m === 'codex' was invisible), normalized to `mode`; the two `m !== 'shell'` display filters are allowlisted and the remaining blind spots documented
- test/run-mode-dispatch.test.ts: table-driven pin of run() dispatch (claude to runClaude, each RUN_MODE_LAUNCH id to _runCliMode(id), shell to runShell, unknown to runClaude, lock held and released)
- CLAUDE.md: name the second CI-gated guard next to the backend one
- server.ts: every </head> injection passes a replacer function; a clis.json label containing $' re-injected the rest of the document past escapeScriptJson (two render tests pin it, proven failing on the string form)
- _isAltCliMode(): no reference anywhere in the tree, nothing to fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1ea363ff808a62861559bc141e724b163cc1c56e)
2026-09-21 04:37:45 +02:00
Codeman maintainer d3f2ec0220 fix(custom-model): merge-time fixes for the promoted-model picker (#459)
The maintainer's promised follow-ups to opticon454's picker promotion,
applied on the landing branch after the merge (ecb95b5d):

- session-ui.js: the promotion tag ("Currently loaded" / "Last used") and
  the "Default" pill are two separate spans, so a promoted row that is
  also the endpoint's defaultModelId shows both instead of silently
  losing its Default marking; two tests pin it (both fail on the old
  exclusive-slot rendering).
- styles.css: a dedicated #customModelPickModal .set-scope rule, since
  the pill was only styled inside the three settings modals and rendered
  as plain body text here; same skin tokens, modal layout untouched.
- docs/wiki/Custom-Model-Endpoints.md: describe the promotion (currently
  loaded, else last used per device), the separate Default pill, and
  that nothing is ever auto-chosen.
- CLAUDE.md + docs/custom-model-endpoints.md: credit the real "Last used"
  writers (_runCustomModelEntryViaRestart and
  _quickStartWithCustomModelConfirm; runCustomModelEntry only dispatches
  since 88e5b7b2) and drop the now-wrong "both defer to Default" sentence.
- Not done: moving the one-shot "last used" write into
  _runCustomModelEntryOneShot, because the existing one-shot tests assert
  that _quickStartWithCustomModelConfirm writes the key itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0cb0f911adc14a852ba5c2951768a4aa87c25657)
2026-09-21 04:30:01 +02:00
Codeman maintainer 6ef71ec3b9 chore: thanks for 1.32.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:28:22 +02:00
Codeman maintainer d47f93abdb chore: changesets for #453, #444, #459 and #458
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:27:17 +02:00
Codeman maintainer dcf9437308 Merge pull request #460 from Ark0N/feat/installer-v2
feat(install): three questions up front, an unattended build, and a URL you can scan
2026-09-21 04:23:15 +02:00
Codeman maintainer aa13af1f7f Merge pull request #444 from DodgyBadger/feat/mobile-prompt-composer
feat(mobile): add manual prompt composer
2026-09-21 04:23:15 +02:00
Codeman maintainer 9a2e14a93a Merge pull request #453 from timkjr/feat/split-pane-sessions
feat: split-pane sessions — view two live terminals side by side
2026-09-21 04:23:14 +02:00
Codeman maintainer ecb95b5d67 Merge pull request #459 from opticon454/feature/run-menu-picker-currently-loaded-model
feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker
2026-09-21 04:23:14 +02:00
Codeman maintainer a7452dc046 Merge pull request #458 from opticon454/followups
feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
2026-09-21 04:23:13 +02:00
Codeman maintainer 72d437ab63 fix(install): fold in both reviews of #460
The two reviews on the PR (DeepSeek Harness, then Claude) found one class of
bug twice and a list of smaller ones; all of them land here, each pinned in
test/install-sh-invariants.test.ts and, where it is bash logic, driven in the
bash:3.2 CI step as well.

The Start line the done screen prints is now composed in one place
(start_command_hint) from every non-default value, the same five the exec
branch exports through export_bind_env, so "do not start" under a sub-path or
a custom port no longer prints a bare `codeman web`. The --lan / --tailscale /
env preset paths read ${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}: a flag re-run on
a unit that carried a password used to rewrite it without the password and
with the unauthenticated ack. --password and --port flip RECONFIGURE so they
reach the unit instead of taking the quiet update path, and `install.sh name`
re-syncs the unit's base URL after the mapping is re-added.

Also: the sudo keepalive is ended before the exec into the foreground server
(exec skips the EXIT trap, and the loop keys on $$); Ctrl+C in the HTTPS-toggle
poll is trapped for the poll only and skips Tailscale for the run instead of
killing the installer; uninstall asks before removing a LaunchDaemon this
installer never wrote; a foreign daemon gets a launchctl kickstart hint and the
done screen stops claiming the new build is running; the preflight summary
reads the Tailscale state with a line grep when node is not installed yet; the
LAN security notice uses the configured port; a bare re-run ends on the done
screen; a build failure after a rename names the install.sh tailscale
recovery; TS_JOINED_HERE (written, never read) is gone; the plan doc and
architecture-invariants say what the code does. A minor changeset is included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 03:03:31 +02:00
Codeman maintainer 1ba0684438 docs(install): describe installer v2 and the Tailscale naming options
README, the Installation / Remote-Access / Running-As-A-Service wiki pages,
docs/security-architecture.md and CLAUDE.md describe the three-question flow,
the flags, the subcommands, the sub-path answer for an occupied :443 and why
the rename is opt-in. docs/installer-v2-plan.md is the design and the
verification record (what was measured, what still needs a fresh machine);
docs/tailscale-installer-plan.md points at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:38:12 +02:00
Codeman maintainer af744bdb54 feat(install): ask three questions up front, then install unattended and end on the URL with a QR code
The installer used to ask about ten things, half of them after a multi-minute
build, and the question that matters most (how do I reach the dashboard) came
last. It now looks at what is on the machine, asks at most three questions
(access, an optional tailnet name, service), and does the rest unattended.

- Every step that needs a human runs before the build: one consent for all
  missing packages, one sudo prompt kept warm for the run, the AI CLI menu,
  and the Tailscale install/login/operator/HTTPS-toggle preflight (the toggle
  is polled and the admin page opened in a browser, instead of "re-check
  now?").
- The build, the service and `tailscale serve` run behind spinners with their
  output in ~/.codeman/install.log; the tail is shown on failure and a failed
  dependency install names its step.
- The done screen leads with the URL (tailnet, network, this machine) and a
  terminal QR code from the qrcode package Codeman already ships.
  `install.sh status` prints it again.
- Tailscale is two halves: tailscale_prepare (question phase) decides the
  serve SHAPE, tailscale_apply (after the build) issues the one serve command.
  When :443 already belongs to another app, Codeman goes under a sub-path
  (serve --set-path /codeman + CODEMAN_BASE_URL in the unit; serve strips the
  prefix, Codeman's ingress tolerates that, --base-url covers the URLs it
  emits) or a second port, instead of replace-or-nothing.
- Renaming the node to codeman-<hostname> is opt-in and defaults to no
  everywhere (the tailnet name is the machine's ssh identity); --name and
  `install.sh name` do it, uninstall offers the old name back. Serve config is
  keyed by the DNS name, so a rename takes our mapping down first and re-adds
  it under the new name.
- Flags pipe through `bash -s --`: --tailscale|--lan|--local, --name|--no-rename,
  --service|--run|--no-start, --yes, --password, --port. --port is now also
  written into the service file.
- npm install runs with CODEMAN_NO_AUTOSTART=1: postinstall otherwise builds
  and starts a detached `codeman web` on 127.0.0.1:3000, which made the
  service crash-loop on EADDRINUSE while the done screen reported "running"
  off the orphan (fresh Ubuntu 24 sandbox).
- The LAN address comes from the default route, not the first interface.
- A foreign /Library/LaunchDaemons/com.codeman.web.plist is left alone
  instead of being replaced by a LaunchAgent.
- The cloudflared question leaves the main flow (`install.sh cloudflared`).
- "Continue WITHOUT a password?" defaults to yes (owner decision).

Tests: the invariants test pins no `serve reset`, no funnel, no Tailscale
Service, every serve mutation through ts_cmd_serve, rename before shape,
flag/header parity, the rename default and the NO_AUTOSTART opt-out; the CI
bash 3.2 step drives the question phase with stubbed tailscale state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:38:11 +02:00
timkjrandClaude Sonnet 5 46d8b92049 fix(split-pane): port Ctrl+Shift+C's never-falls-through guarantee to Pane B
The smart-copy gate only entered its selection-check block behind
hasSelection(), so a selection-less Ctrl+Shift+C skipped straight to
`return true` and ceded the keystroke to the browser's own handling
(e.g. Chrome's Inspect-Element binding) instead of matching Pane A's
"never falls through" contract for that chord.

Verified live in a real browser that this is a UX-parity fix, not an
interrupt-safety one: xterm's evaluateKeyboardEvent never emits PTY
data for a shifted ctrl-letter regardless of any gate (only "_" and
"@" get special-cased), so no accidental 0x03 was ever at risk. The
regression test added here asserts on the dispatched event's
defaultPrevented rather than the absence of a WS frame, since the
frame-count check passes vacuously for this exact key combo whether
or not the gate fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00
timkjrandClaude Sonnet 5 0b3e086334 fix(split-pane): address Ark0N's fourth pass — PTY-less picker exclusion, hollow chord test, remaining key gates
- buildSplitPickerSessions() now excludes any session with pid === null
  (exited CLI, tripped PTY-exit breaker, a restore that never re-attached).
  Pane B has no equivalent of selectSession()'s auto re-attach POST, so a
  split opened onto one had nothing reading its tmux pane: no terminal
  events ever arrived and Session.write() silently dropped every keystroke
  with no ack either way, while the socket itself reported healthy.
- Fixed the hollow chord regression test: the synthetic keydowns carried no
  keyCode, which is what xterm's evaluateKeyboardEvent switches on to
  produce a data frame at all, so the assertion held regardless of whether
  the gate fired. Adding real keyCodes surfaced a second, real bug in the
  Alt+B case: the event bubbles to app.js's own document-level shortcut
  dispatcher, which really toggles the sidebar and resets the layout
  attribute the gate reads before Pane B's own (later, non-capture) handler
  ever sees it — fixed by driving the app's real settings cache instead of
  only the DOM attribute.
- Ported the two remaining primary-pane gates with real consequences:
  Ctrl+Z (SIGTSTP) is swallowed for every non-shell session, matching
  terminal-ui.js's reasoning (an Ink/TUI agent loop stops dead with no
  visible output otherwise), and Shift/Ctrl+Enter now POSTs to
  /api/sessions/:id/send-key for THIS pane's own session instead of
  letting xterm send a bare \r, which used to submit an incomplete prompt
  instead of inserting a newline. Smart-copy Ctrl+C is re-implemented
  against Pane B's own terminal (copying app.copyTerminalSelection() would
  have copied Pane A's selection instead).
- Updated docs/architecture-invariants.md and docs/split-pane-sessions-plan.md
  to match, and added CLAUDE.md's missing .split-picker-menu z-index entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00
timkjrandClaude Sonnet 5 fafef0aa00 fix(split-pane): gate app-level chords out of Pane B, address Ark0N's third pass
Pane B had no attachCustomKeyEventHandler of its own, so the document
capture-phase shortcut handler's preventDefault() (which does not stop
xterm) left Ctrl+K/Alt+1/Alt+B ALSO writing their raw byte/escape
sequence into Pane B's live PTY on top of whatever the app action did
to Pane A. Pane B now installs the same registry-aware gates the
primary pane's own attachCustomKeyEventHandler uses. Ctrl+V is left on
xterm's default paste — no image-paste trap to route it to.

Plus the rest of the review's smaller items:
- Narrowing the window past the desktop gate now closes an open split
  instead of leaving it stranded on screen.
- Split is refused while a web tab is active (activeWebviewId), which
  used to open Pane B's socket behind a hidden container.
- Pane B now handles the server's `{t:'r'}` refresh frame via a shared
  _loadBuffer() helper (also used by connect()), instead of ignoring it.
- The divider drag now uses pointer events + setPointerCapture (mirrors
  tab-rail-resize.js), a button!==0 guard, preventDefault, and a
  body.split-pane-resizing cursor/selection lock — a plain mousedown
  drag selected the text under the cursor as it crossed both terminals.
- Pane B's close control and the picker rows are real <button>s now
  (keyboard-reachable), with matching CSS chrome resets.
- Dropped the redundant CodemanBase.base prefix on the buffer fetch
  (the global fetch wrapper already applies it).
- data-preview-order for the Split settings chip moved from a collision
  with Ultracode Agents (both 15/12) to 11.5, matching its real
  position between Multi-monitor and Ultracode Agents in the header;
  widened test/app-settings-structure.test.ts's regex to allow the
  decimal (Number() already parses it fine for the preview sort).
- Added zh-CN i18n entries for the Split button and empty-picker text.
- Dropped the stray unused `vi` import Ark0N flagged as unrelated to
  this feature (vitest's `globals: true` makes it ambient anyway).
- Documented the fix and the deliberate no-cid/seq choice in the
  split-pane-sessions architecture-invariants entry.

Added a real-Chromium regression test asserting Ctrl+K/Alt+1/Alt+B
dispatched at Pane B's own textarea send no `{t:'i'}` frame over its
WebSocket. Full CI gate green (409 files, 7736 tests) plus all 8
split-pane browser tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:33 -05:00
timkjrandClaude Sonnet 5 3152ec801d docs(split-pane): short CLAUDE.md rule, stale module count, wiki entries, shortcut-handler caveat
CLAUDE.md previously only mentioned split-pane in the load-order list,
with nothing in the Architecture/frontend prose the way every other
feature gets, and its own module count was one stale (34, should have
been bumped to 35 when terminal-split.js was added). Add a short
pointer-style paragraph next to the other terminal features, fix the
count.

docs/wiki/The-Dashboard.md's header button table and
docs/wiki/Settings-Reference.md's header chips list are the two
user-facing surfaces that never mention Split at all; added both, plus
a note that the feature is desktop-only regardless of the setting.

docs/split-pane-sessions-plan.md: recorded the one design note that
isn't a code change — the global capture-phase shortcut handler always
resolves against Pane A, so Ctrl+L/Ctrl+W typed into Pane B affects the
other session. Not fixed for v1, same reasoning as the rest of the
"deliberately plainer" section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:33 -05:00
timkjrandClaude Sonnet 5 2e3e245cc6 fix(split-pane): throttle the drag, chunk the scrollback, and the rest of Ark0N's second pass
Two majors:
- The divider drag was unthrottled: every mousemove did a full xterm
  reflow on BOTH panes and sent Pane B a {t:'z'} resize frame with no
  unchanged-dimensions skip, fanning out into a `tmux resize-window`
  child plus a SIGWINCH per event — ~50 of each dragging across half a
  wide viewport. SplitTerminalPane.fit() is now split into localFit()
  (reflow only) and fit() (reflow + send); the drag coalesces moves
  into one localFit() per animation frame via requestAnimationFrame,
  and sends the real resize for both panes exactly once, at drag end,
  matching the primary pane's own throttledResize convention.
- Pane B pulled the FULL scrollback unchunked for every session mode,
  writing it in one terminal.write() call. Mirrors the primary pane's
  own mode check (app.js's selectSession): shell sessions get a
  bounded 1MiB ?tail= fetch instead of ?full=1, and the fetched buffer
  is written through a minimal chunked writer (32KB slices, yielding a
  frame between each) instead of one primary-pane chunkedTerminalWrite
  this simpler, independently created/destroyed pane has no equivalent
  of (no session-switch generation counters or live-output gate).

Smaller items from the same review:
- Pane B now follows live appearance changes (applyTerminalSkin,
  applyTerminalFontFamily, applyTerminalFontWeights, setFontSize all
  propagate to it, matching the teammateTerminals pattern) and reads
  the real codeman-font-size/terminalFontFamily/weights/DEFAULT_SCROLLBACK
  settings at construction instead of hardcoding fontSize 14 / scrollback 5000.
- The Pane-B-promotion path now skips selectSession() when
  _closingSessions already owns this delete (the user closing Pane A's
  own tab), matching _onSessionDeleted's own active-session-handoff guard.
- Detaching a session AFTER a split is already open now yields the PTY
  size in _sendResize() too (not just at picker-open time), mirroring
  sendResize's own detachedElsewhere guard.
- .btn-split joins the body.solo-mode hide list, next to .btn-multimonitor.
- The split row was 6px wider than its container (two flex-shrink:0
  50% panes plus a 6px divider): both panes are now flex-shrink 1.
- Pane B's header and the split-picker rows are marked so i18n.js's
  exact-string lookup skips them, matching .session-name elsewhere —
  a session literally named e.g. "Sessions" was translatable on zh-CN.
- The Split button now reflects open/closed state via a `.split-open`
  accent style, aria-pressed, and a title/aria-label that says which
  behaviour the next click gets.
- _splitPane.connect() is no longer an unawaited call with no .catch().
- terminal-split.js's fileoverview pointed at a doc path that was
  renamed away in the previous push; @dependency now credits
  constants.js for CodemanTerminalFont, not terminal-ui.js.
- index.html's Split settings chip no longer reuses data-preview-order
  "12" (already the Ultracode Agents chip's slot in the same "header"
  preview group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 165cfb52d6 fix(split-pane): stop breaking every settings save, finish the desktop gate
Blocker from Ark0N's second PR #453 pass: moving showSplitButton into
settings-ui.js's per-device displayKeys set was only half of making it
per-device. saveAppSettings() still put it in the object PUT to
/api/settings, SettingsUpdateSchema (.strict()) does not declare it,
the server answered 400 INVALID_INPUT, and because the call site never
checked res.ok the UI still reported "Settings saved" while NOTHING
persisted — workspaceHooksEnabled, agentSkillEnabled, tunnelEnabled,
claudeModel, every toggle, on every save, on every device. Strip it
out via the same destructure every other per-device key goes through
(`showSplitButton: _ssp,`), drop the stray mention from a schemas.ts
comment (a mention there reads as "this is a real field" to the next
grep), and add a static guard test mirroring
test/terminal-auto-copy.test.ts's three-way rule.

Also finishes the desktop gate the first pass only did in CSS at
599px: SPLIT_PANE_MIN_WIDTH (1180, matching HOME_SESSIONS_MIN_WIDTH)
now backs an actual JS width check in _applySplitButtonVisibility,
with a matchMedia listener so a live window resize hides/shows the
button without a reload — the CSS backstop in styles.css is the
reverse-direction guarantee for when JS hasn't run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 6c8bd6c606 fix(split-pane): gate the Split button to desktop, make it per-device
Ark0N's PR #453 review: nothing gated this feature to desktop even
though the design called for it (two 240px min-width panes plus the
divider need ~486px, and the divider has no touch handlers), and
showSplitButton was a SYNCED setting, so turning it on at a desk also
put the button in the phone header.

- Hard-hide .btn-split on phones in mobile.css regardless of the
  setting, matching the other desktop-oriented header buttons in the
  same @media (max-width: 599px) block.
- Move showSplitButton into settings-ui.js's per-device displayKeys
  set and drop it from SettingsUpdateSchema entirely, matching the
  showFileViewerButton/skin precedent (CLAUDE.md's "per-device keys
  ... must NOT be added to SettingsUpdateSchema" rule) — a desktop
  opt-in must never sync onto a phone that never asked for it. Removes
  the now-invalid server-round-trip test for the setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 8d3bde5469 fix(split-pane): address the rest of Ark0N's PR #453 review
- Exclude popped-out (detached) sessions from the split picker:
  SplitTerminalPane._sendResize() has no yield-to-detached-window check
  the way the primary pane's sendResize() does, so splitting against a
  detached session put its own window and Pane B in a fight over the
  same PTY's dimensions. Simplest fix per the review: keep them out of
  buildSplitPickerSessions() entirely.
- Show a visible dead state when Pane B's WebSocket drops. onData
  already silently discards keystrokes while the socket isn't OPEN
  (there is no reconnect for v1), so a dropped socket left the pane
  looking normal while it quietly ate everything typed into it.
- openSplitPane() returns early with no active session, so a split
  triggered from the home screen no longer creates and connects Pane B
  behind the opaque welcome overlay with nothing to show for it.
- onMove() during a divider drag now bails when the split has
  auto-collapsed mid-drag (the other pane's session ending) instead of
  throwing on `divider.parentElement` being null.
- Promote Pane B via `selectSession(id, { auto: true })` when Pane A's
  session ends — this is an app-driven selection, not the user clicking
  a tab, so it must not spend the promoted session's idle alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:31 -05:00
timkjrandClaude Sonnet 5 78dcb0aa24 fix(split-pane): refit Pane B when the window/sidebar/tab-rail resizes
Ark0N's PR #453 review: fit() was only ever called from the divider
drag, and the trailing-edge ResizeObserver callback in terminal-ui.js
(throttledResize) only ever measured Pane A's own container. Split at
a wide viewport, shrink the window (or toggle the Alt+B sidebar, or
drag the tab rail), and Pane A's cols changed while Pane B silently
kept its stale PTY size in both xterm and the real pane.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:31 -05:00
timkjrandClaude Sonnet 5 7fc66e8161 docs(split-pane): keep the design spec, drop the task-plan scaffolding
Per Ark0N's review on PR #453: rename the design spec to
docs/split-pane-sessions-plan.md, matching every other feature's
*-plan.md convention, and drop the 957-line implementation task plan
(docs/superpowers/plans/2026-09-15-split-pane-sessions.md) — workflow
scaffolding for the subagent-driven-development run, not repo
documentation. Fixes the now-dangling link in architecture-invariants.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00
timkjrandClaude Sonnet 5 1678386f50 test(split-pane): cover the blank-Pane-B and stale-width-Pane-A fixes
Real-browser regression coverage for the previous commit:

- SplitTerminalPane connects onto an already-quiet session and shows its
  existing scrollback with no new output, proving the ?full=1 fetch (not
  a live echo) populated the pane.
- openSplitPane() force-resizes Pane A synchronously as part of opening
  a split.
- Dragging the divider force-resizes Pane A once, at drag end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00
timkjrandClaude Sonnet 5 3859506f9b fix(split-pane): populate Pane B history and force-resize Pane A on split changes
Pane B's SplitTerminalPane.connect() only opened a WebSocket and waited for
live output — ws-routes.ts's terminal socket sends nothing on connect, only
future 'terminal' events — so it stayed blank until the target session
happened to produce new output. It looked intermittent rather than
always-broken because a resize sent by _sendResize() often nudges the
session's real tmux window to a new size, and tmux repaints its current
screen on resize; that incidental repaint was what usually populated the
pane. When Pane B's computed dimensions already matched the session's
last-known size, Session.resize() skipped the resize as a no-op and the
pane stayed empty. Fetch the existing scrollback (?full=1) before opening
the socket, same as the primary pane does.

Pane A never told its own session's PTY/tmux about a size change at all,
relying purely on the passive 300ms-debounced ResizeObserver in
terminal-ui.js. openSplitPane() now force-resizes Pane A immediately on
entering split (mirroring closeSplitPane()'s existing symmetric call), and
the divider-drag handler force-resizes it once at drag end (matching the
codebase's established trailing-edge debounce convention rather than
flooding a resize per mousemove).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 33b2605815 fix(split-pane): stop leaking document listeners on repeated split-picker toggles
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 d0a887d98a test(split-pane): add fast unit coverage for the _onSessionDeleted auto-collapse ordering
test/split-pane-auto-collapse.browser.test.ts covers "Pane B's session ends"
in a real Chromium, but that suite is excluded from the npm test CI gate.
The "Pane A's session ends, Pane B gets promoted" branch had no coverage
anywhere, and it is the one branch whose correctness depends on exact
ordering: _splitSessionId must be captured BEFORE closeSplitPane() runs
(which nulls it) or the promoted session id is lost. Loads terminal-split.js
via `vm` against a minimal fake CodemanApp (same technique as
test/session-close-fallback.test.ts), and pins all three branches (Pane A
ends, Pane B ends, unrelated session ends) plus that the original
_onSessionDeleted always still fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 24a92c8f3e fix(split-pane): refuse to split a session against itself
Nothing stopped a stale picker click (opened before switching tabs) or
clicking Pane B's own session tab while split from landing on
openSplitPane(sessionId) with sessionId === activeSessionId, or from
selectSession() rebinding the primary pane onto the session Pane B was
already showing — either way, two live WebSockets to one session, each
independently claiming PTY dimensions via its own {t:'z',...} resize frame.
openSplitPane() now refuses early when the target is already the active
session, and a new selectSession() prototype patch (same top-level pattern
as the existing _onSessionDeleted patch) closes an active split BEFORE the
primary pane rebinds to the session Pane B holds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 f7852081b7 docs(split-pane): fix orphaned Session list layout section
The new "Split-pane sessions" section was inserted between the "Session
list layout (header strip vs. left sidebar)" heading and that section's own
body paragraphs, orphaning the heading from its content. Move "Split-pane
sessions" to after the Session list layout section's full body, before
"Gesture control: the setting" — no change to the Session list layout prose
itself, only where the new section sits relative to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 f0e24d8ce2 fix(split-pane): hide the split container together with the rest of the terminal on a web tab
.main.webview-active hid .terminal-wrap when a web tab became active, but
.terminal-wrap is reparented INSIDE .terminal-split-container while a split
is open, so Pane B and the divider stayed stranded on screen over the
dashboard iframe. Hide the whole split container as one unit, mirroring the
existing .terminal-wrap rule; no state is destroyed, so returning to the
session tab shows the split intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 2724c922ce fix(split-pane): style and dismiss the split-picker menu
.split-picker-menu/-item/-empty (created in openSplitPicker()) had zero CSS
and could not be dismissed except by picking an item — a default-path defect
since the Split button ships enabled to anyone who flips showSplitButton on.
Add CSS matching the sibling .run-mode-menu popover's look (floating-bg
backdrop blur, border, shadow, z-index 1000 above the header's 100), and
dismiss on outside click or Escape via the same one-shot listener pattern
session-ui.js already uses for its other transient popovers
(toggleCaseSettings(), toggleRunModeMenu()). Picking an item now routes
through the same _dismissSplitPicker() method as the outside-click/Escape
handlers, so the listeners never outlive the menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjrandClaude Sonnet 5 57406f6c14 fix(split-pane): stop clamping Pane B's resize dimensions to a 40x10 floor
_sendResize() clamped Pane B's proposed cols/rows to a 40/10 floor before
sending the {t:'z',...} resize frame, so the PTY was misinformed of Pane B's
real width at the divider's own reachable 20% position, causing real
output-wrapping bugs. The primary pane (terminal-ui.js's
getTerminalDimensions()) sends fitAddon.proposeDimensions() unclamped and
lets the server enforce its own valid range ([1,500]/[1,200] in
ws-routes.ts); Pane B now matches that convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjrandClaude Sonnet 5 8903a72662 fix(split-pane): hide the Split header button when showSplitButton is off
.btn-split--hidden had no matching CSS rule anywhere, so the opt-in Split
header button shipped visible to every user on every viewport regardless of
the setting. Add the `display: none !important` rule alongside its sibling
marker classes (.btn-multimonitor--hidden etc.), plus a static regression
guard (test/split-pane-hidden-button-css.test.ts) that fails if any future
"*--hidden" marker class in index.html is missing a matching CSS rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjr ba7b8b7bef docs: add split-pane sessions architecture-invariants entry 2026-09-20 13:10:26 -05:00
timkjr 8c73128cd6 feat(split-pane): auto-collapse split when either session ends 2026-09-20 13:10:26 -05:00
timkjrandClaude Sonnet 5 2abf328db8 feat(split-pane): add open/close orchestration, picker, and divider drag
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 fa8bb13a27 fix(split-pane): reset _wsReady on WS close/error in SplitTerminalPane
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 6f64e557e5 docs(plan): fix session-creation test bug found by Task 4's implementer
Task 4's implementer found two real bugs in this plan's browser-test
helpers: POST /api/sessions nests the id at data.session.id (not
data.id), and mode:'shell' needs a follow-up POST .../shell to actually
spawn a PTY. Fixed in Task 4's own snippet (documentation accuracy —
already fixed in the real committed code) and pre-emptively in Tasks
5/6's createShellSession() helper before either was dispatched, so
neither implementer has to rediscover it independently. Also corrected
the <script> tag snippet to defer, matching the real file's convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 97a1238c85 feat(split-pane): add SplitTerminalPane class for Pane B
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:24 -05:00
timkjr aa0521602d feat(split-pane): add split container/divider/pane-b CSS 2026-09-20 13:10:24 -05:00
timkjr 2bc16d5fd9 feat(split-pane): add showSplitButton setting and header button 2026-09-20 13:10:23 -05:00
timkjr d60a164025 feat(split-pane): add pure divider-clamp and picker-list helpers 2026-09-20 13:10:23 -05:00
timkjrandClaude Sonnet 5 727817410c docs(plan): fix Task 6's SSE handler patch to target the prototype
Monkey-patching the instance's _onSessionDeleted inside a
DOMContentLoaded listener races connectSSE()'s handler-wrapper cache,
which captures the function reference by value on first connect and
never re-reads it. Patching CodemanApp.prototype at module-evaluation
time (synchronous script-tag order) is unraceable: it completes before
any instance exists or connectSSE() ever runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 28d3bd7da8 docs(plan): fix Task 2/4/5/6 tests against real test infrastructure
Preflight scan for SDD execution caught two classes of defect before
dispatch: Task 2's test invented a buildTestApp() helper and response
envelope that don't exist for /api/settings; Tasks 4-6 used
@playwright/test's runner against a test/browser/ directory that
doesn't exist in this codebase. Both corrected against real patterns
found in existing tests (system-routes-settings-partial-put.test.ts,
terminal-copy-shortcut.test.ts, tab-rail-resize.browser.test.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 d0f9bdd251 docs: fix plan wording and add execution-environment note
Global Constraints previously read as if local-echo/CJK/accessory-bar
were desktop features; they are mobile-only, and split-pane is the
desktop-only side of that equation. Also names the exact spec section
instead of a loose paraphrase, and adds a worktree/branch note so an
executing subagent knows where this plan runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 ff98006471 docs: add split-pane sessions implementation plan
7 tasks: pure divider/picker helpers, showSplitButton header wiring,
split-container CSS, SplitTerminalPane (Pane B's independent xterm+WS),
open/close orchestration with picker and divider drag, auto-collapse on
either session ending, and an architecture-invariants entry.

Also folds in the "detach session" prior art discovered mid-brainstorm
into the spec's architecture section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:21 -05:00
timkjrandClaude Sonnet 5 5f1be90ae9 docs: fix tab/pane terminology in split-pane spec
The Problem paragraph and the architecture section used "tab" where
"pane" was meant, colliding with the browser's own tab concept.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:21 -05:00
timkjrandClaude Sonnet 5 3da8bb7046 docs: add split-pane sessions design spec
Scopes v1 of an in-app split view (two live session panes side-by-side,
draggable divider) after multi-monitor spanning turned out to solve a
different problem than showing multiple panes at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:20 -05:00
DodgyBadger ac574d6c64 fix(mobile): compact the compose key 2026-09-20 17:31:10 +00:00
DevvynandClaude Sonnet 5 d9e6ebb20a fix(cli-registry): address round-2 review on #458 — count-based allowlist, RUN_MODE_LAUNCH drift guard
Three of Ark0N's four "will take at merge" items, applied instead since
they were straightforward to do properly:

1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on
   <file>::<expression> (fixed last round) closed the line-shift problem
   but opened a new one: every stock id was already allowlisted for
   session-ui.js in the `mode === '<id>'` form, so a BRAND NEW branch
   reusing that exact expression anywhere in the file passed unnoticed.
   Reproduced live (`if (this.mode === 'codex')` injected into
   runOpenCode()) — stayed green under the old version. Each allowlist
   entry now carries the exact count of approved call sites, and a new
   test asserts actual-vs-declared count for every key; a mismatch in
   either direction is real (higher = new unreviewed branch riding in on
   an existing approval, lower = a reviewed site was removed and the
   entry is now stale). Reproduced again against the fix: same injection
   now fails with an exact diagnostic (expected 2, found 3).

2. Added test/run-mode-launch-table-drift.test.ts. RUN_MODE_LAUNCH
   restates four things stock.ts already owns (label, install command,
   supportsCustomModel, the external-mode key set), and they agree today
   with nothing enforcing it. supportsCustomModel is the dangerous one:
   the Run-menu picker's rows come from the server-injected
   window.__codemanCustomModelClis (built from
   capabilities.customModelInjection.kind), so a CLI gaining a real
   injection recipe later would be OFFERED in the picker while
   _runCliMode silently drops the customModel field for it — the session
   launches on the vendor's cloud while the UI claims the local endpoint.
   Drives the real session-ui.js via JSDOM and compares RUN_MODE_LAUNCH
   against STOCK_CLIS on all four axes.

3. Inlined the "Open Question 7 in PR-B2.md" references in the allowlist
   reasons — PR-B2.md is a local planning doc, never part of the
   committed tree, so the reference was dead on arrival for anyone
   reading the repo. Points at the PR #458 review thread instead.

4. Added a sentence to docs/cli-registry.md naming the new frontend guard
   alongside the backend one it mirrors.

Full gate: 406 files / 7721 tests / 0 failures, typecheck/lint/format/
check:frontend-syntax all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-20 21:37:05 +08:00
DevvynandClaude Sonnet 5 88e5b7b200 fix(custom-model): address Ark0N's PR review — client-side probe timeout, defer "last used" past confirmation, docs, zh-CN
Four things from the maintainer's review on PR #459, all fixed:

1. Bound _getCustomModelCurrentlyLoaded's probe client-side (~800ms via
   Promise.race, on top of — never instead of — the route's own 5s
   server-side timeout). Without it, an asleep/firewalled endpoint behind
   a saved model list left the picker completely invisible for up to 5s
   after the Run menu had already closed, with no spinner or toast.
   `timeoutMs` is an optional param (default 800, real callers never pass
   it) so a test can drive it in milliseconds, same pattern as
   `_watchLlamaSwapLoading`'s own `pollIntervalMs` — this code runs in a
   JSDOM window's own realm, whose setTimeout vi.useFakeTimers() cannot
   patch.

2. "Last used" is now written only once a launch actually applies, never
   on the mere click. It moved out of runCustomModelEntry (unconditional)
   and into each path's own success point: _quickStartWithCustomModelConfirm
   after the final post succeeds, and _runCustomModelEntryViaRestart right
   after the apply's success check. A context-window-warning decline means
   this exact model cannot work with this CLI at all, so the old
   unconditional write would promote, next time the picker opened, the one
   model guaranteed to fail again.

3. Documented the promotion/tag precedence and the new
   codeman:customModelLastUsed:<mode>:<endpointId> localStorage key in both
   CLAUDE.md's Custom Model Endpoint Profiles section and
   docs/custom-model-endpoints.md's Run-menu picker section.

4. Added zh-CN entries for "Currently loaded" and "Last used" in i18n.js,
   next to this modal's existing "Choose a model"/"Custom Endpoints" pair.

New tests: the client-side timeout (endpoint that never answers, one that
answers within the bound, and a rejected-after-timeout probe settling
quietly), and "last used" recording on success vs. NOT recording on either
confirmation's decline, for both the restart and one-shot paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 21:19:42 +08:00
DevvynandClaude Sonnet 5 73607663fd fix(custom-model): guard the picker's async open against a slower, superseded probe
Code review (high effort) on the previous commit found a real race: making
_openCustomModelPickModal async (it now awaits the currently-loaded-model
probe before rendering) meant a second, faster call for a different
endpoint could render first, only for the first call's slower probe to
resolve afterwards and overwrite the modal with the wrong endpoint's model
list — while _pendingCustomModelPick (set synchronously, before either
await) still named the second, correct endpoint. Picking a model in that
state would launch/apply the wrong model on the wrong endpoint.

Fixed with the same mutable-generation-counter guard
_watchLlamaSwapLoading already uses for an identical async-superseded-by-
newer-call shape: every DOM write, including _pendingCustomModelPick
itself, is deferred until after the awaited probe, and a call that finds
its generation already superseded bails out untouched instead of clobbering
whatever a newer call already rendered.

Added a regression test driving two overlapping opens with a controlled
promise so the earlier, slower probe resolves after the later, faster one
renders, asserting the late response is a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 19:27:07 +08:00
DevvynandClaude Sonnet 5 458ca578e7 feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker
Custom Model Endpoint Profiles' "which model" picker (session-ui.js's
_openCustomModelPickModal) always listed models in their raw discovery
order, so on a host with several downloaded GGUFs the user had to
remember (or eyeball the "Default" tag) which one llama-swap actually
had hot before picking — the whole point of the picker being fast is
undone if it makes you think first.

The picker now promotes exactly one model to the top of the list:

- If llama-swap reports a model from this host's own list `ready`
  right now (via the existing GET /api/model-endpoints/:id/running-status
  route), that model is promoted and tagged "Currently loaded" — it's
  what a launch attaches to with zero wait.
- Otherwise, the last model actually launched on this exact
  (harness, endpoint) pair is promoted and tagged "Last used", read
  from a new per-device localStorage key
  (codeman:customModelLastUsed:<mode>:<endpointId>), written by
  runCustomModelEntry on every launch attempt regardless of outcome.
- A plain (non-llama-swap) OpenAI-compatible server, an unreachable
  endpoint, or a loaded-but-not-yet-ready model never promotes
  anything — the rest of the list keeps its discovery order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 19:20:45 +08:00
DodgyBadger 884713cca5 fix(mobile): retain oversized composer drafts 2026-09-20 07:21:20 +00:00
DodgyBadger a220c28a14 fix(input): count code points when clearing prompts 2026-09-20 07:18:49 +00:00
DodgyBadger 0761de3dae fix(mobile): use raw fallback for composed prompts 2026-09-20 07:18:23 +00:00
DodgyBadger c2eaba990b fix(mobile): release echo passthrough after compose 2026-09-20 07:17:41 +00:00
DodgyBadger e214691429 fix(mobile): preserve composer delivery after replay 2026-09-20 07:16:59 +00:00
DodgyBadger 773b405429 feat(mobile): add manual prompt composer 2026-09-20 07:16:59 +00:00
DevvynandClaude Sonnet 5 2df9355367 fix(cli-registry): address PR B2 review — fix two test guards, drop unused catalogue
Two required fixes from Ark0N's review of #458:

1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on
   <file>::<line>::<expression>. A single inserted line anywhere above an
   entry shifted every subsequent line number, so all 21 entries went stale
   simultaneously and the same 21 branches were reported as "new" — on a
   file six other open PRs also touch. Dropped the line number from the key
   (<file>::<expression>, matching the backend guard's own design), which
   collapses 21 line-keyed entries to 11 or-collapse where the same
   expression recurs at multiple call sites in the same file.

2. test/run-mode-ui.test.ts's terminal-ownership guard scanned method
   bodies via `^ {2}async (run[A-Za-z]*)\(\) \{$`, which matched the 8
   one-line run<Mode>() wrappers PR B2 introduced but not _runCliMode(mode),
   where the real logic (and the actual risk the guard exists to catch) now
   lives. Fixed the regex to `^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$` and
   added _runCliMode to the sanity list. Same-class fix in
   test/opencode-resize.test.ts, which had the identical blind spot via
   runOpenCode.toString().

Both reproduced live before fixing (inserted the same comment line; added
this.terminal.clear() to _runCliMode) to confirm the bug, then confirmed
the fix catches it and the suite stays green otherwise.

Also resolves Open Question 2 by dropping window.__codemanCliCatalog
entirely: nothing consumed it, and a registry DECLARED_FOR_LATER field
costs nothing until read while an unconsumed script tag on every page
render is a different trade. Reverts Phase 1 cleanly — server.ts's
injection, shortBadge back in types.ts's DECLARED_FOR_LATER list and the
pinned guard test, and the three associated render-index-html.test.ts /
server-index-title.test.ts assertions.

Full gate: 405 files / 7717 tests / 0 failures (net unchanged), typecheck/
lint/format clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-20 02:04:37 +08:00
DevvynandClaude Sonnet 5 cd64b0a3f7 feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
PR #380 (PR B) held back the frontend half of the CLI registry refactor,
explicitly deferring window.__codemanCliCatalog and making session-ui.js /
mobile-overview.js catalogue-driven as "PR B2".

- Inject window.__codemanCliCatalog in renderIndexHtml(), following the
  existing __codemanCustomModelClis pattern (escapeScriptJson-guarded,
  resolved per-request). Reading CliEntry.shortBadge here is what makes it
  genuinely read, so it drops out of types.ts's DECLARED_FOR_LATER list.
- Consolidate session-ui.js's 8 near-duplicate run<Mode>() launch functions
  (opencode/codex/gemini/antigravity/pi/omp/grok/deepseek) into one shared
  _runCliMode() plus a local RUN_MODE_LAUNCH config table. The 8 method
  names stay as thin wrappers (index.html calls them by name; tests assert
  on the name). Also collapses a duplicated 8-way isAltMode/isExternalCli
  OR-chain (same expression, copy-pasted twice in openSessionOptions) into
  one EXTERNAL_CLI_MODES check.
- Add test/frontend-cli-no-id-branching.test.ts, a guard scoped to
  session-ui.js/mobile-overview.js only (not the rest of src/web/public/,
  which stays explicitly out of scope per CLAUDE.md), mirroring the
  backend's own no-id-branching guard.

mobile-overview.js and the wiring of accent/echo/wheelForward/
keyboardAccessory were investigated and deliberately left alone: the first
is already a single, tested, gated table (not duplicated logic); the second
set belongs to terminal-ui.js/keyboard-accessory.js/styles.css, files
outside this PR's mandate.

Verified on a tmux-capable devbox (this sandbox has no tmux): full CI gate
at 405 files / 7717 tests / 0 failures, typecheck clean, 94 targeted tests
covering exact per-CLI wire-body shapes unmodified and passing, and a live
anti-vacuity check on the new guard (injected a real branch, confirmed it
fails, reverted, confirmed green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-19 21:16:31 +08:00
Devvyn 2d573d8a34 Merge branch 'master' of https://github.com/Ark0N/Codeman into followups 2026-09-19 19:43:45 +08:00
Devvyn 55a80eab86 Merge branch 'master' of https://github.com/Ark0N/Codeman into followups 2026-09-19 08:12:17 +08:00
Devvyn b1db5515d7 Merge branch 'master' of https://github.com/Ark0N/Codeman into followups 2026-09-16 15:23:52 +08:00
243 changed files with 28484 additions and 2085 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.31.0",
"version": "1.33.3",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+5 -2
View File
@@ -28,12 +28,15 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
CI runs all of these, so save yourself a round trip:
```bash
npm run typecheck # tsc --noEmit, strict mode
npm run typecheck # tsc --noEmit, strict mode
npm run lint
npm run format:check
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
```
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
### Tests
```bash
+51
View File
@@ -34,6 +34,13 @@ jobs:
- name: Frontend JS syntax check
run: npm run check:frontend-syntax
# Asks `vitest list` what CI would actually collect, rather than matching
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
# (config/test-suites.ts) passes locally and dies in the test job with
# "browserType.launch: Executable doesn't exist".
- name: Browser-test exclusion check
run: npm run check:browser-excludes
- name: Format check
run: npm run format:check
@@ -100,6 +107,50 @@ jobs:
fi
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
'
# Installer v2: the question phase runs before the build, and every decision it
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
# default, the occupied-:443 menu and the rename question with canned answers,
# so a bash-4 construct or a flipped default in any of them fails here, not on a
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h
. /w/install.sh
parse_flags --tailscale --service --name Build-Box --port 4000
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
has_tty() { return 0; }
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
systemctl() { return 0; }
LAUNCH_PRESET=""; NONINTERACTIVE=0
choose_launch_mode linux >/dev/null 2>&1
[[ "$LAUNCH_CHOICE" == "2" ]]
check_tailscale() { return 0; }
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
ts_backend_state() { printf Running; }
ts_dns_name() { printf box.tail.ts.net; }
ts_serve_443_target_port() { printf 8080; }
ts_serve_find_port_mapping() { :; }
ts_serve_port_used() { return 1; }
detect_tailscale_serve_url() { :; }
tailscale_choose_mapping >/dev/null 2>&1
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
[[ -z "$RENAMED" ]]
# A flag re-run keeps the password the unit already carries (and so
# never writes the unauthenticated ack), and the hand-start line the
# done screen prints carries every non-default value.
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
choose_network_binding >/dev/null 2>&1
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+161
View File
@@ -1,5 +1,166 @@
# aicodeman
## 1.33.3
### Patch Changes
- f776ad8: ### Thanks
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
## 1.33.2
### Patch Changes
- e439cf0: ### Thanks
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
## 1.33.1
### Patch Changes
- ### Thanks
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
Thanks @opticon454.
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
Thanks @opticon454 (#487, #485).
## 1.33.0
### Minor Changes
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
### Thanks
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
- @julian3xl for the Session Manager row fix (#477), their first contribution.
### Patch Changes
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
## 1.32.1
### Patch Changes
- 13e652e: Terminal copy: copying text out of a Claude Code or Codex pane no longer puts the pane's two-column transcript gutter on the clipboard, so pasted lines arrive flush instead of indented (#469). The width comes from the CLI registry (`capabilities.transcriptGutter`, 2 for claude and codex, measured on live panes) and is only a ceiling: a selection only ever shifts as a block, so its own indentation survives. Other CLIs and shells are untouched. It works in split panes and detached session windows too, and can be turned off per device in App Settings under Selection & clipboard.
- 13e652e: Sessions: recovering a Claude session whose tmux pane had died relaunched `claude --session-id <id>`, which Claude refuses once that id has a transcript, so the pane died again straight away and the conversation was stranded. The relaunch now resumes the conversation (`--resume <id> || --session-id <id>`), including when tmux lost the whole session (#467).
- 00f022c: Terminal: when a burst of output overflows the render queue and a frame has to be dropped, the repaint that repairs it is now retried until it actually happens, instead of being scheduled once and silently skipped when another load was in flight (#470).
- 13e652e: Mobile: a long press on blank terminal space on Android Chrome no longer opens the keyboard and blanks the terminal (#471, fixes #360). The long-press guards are now armed before the press is checked for selectable text, so a press on empty space is swallowed the same way a press on a word already was.
- 13e652e: Sessions: a tab whose agent has exited (the CLI quit, but tmux kept the pane) now says so with a muted dot and an `exited (137)` badge, instead of looking like an idle session (#466, part 1 of #446). The state is published as `paneExit` on the session and survives a restart. Nothing closes such sessions yet; that is part 2.
- 13e652e: Docker: optional GitHub CLI and Azure CLI for private repositories (#472). Both are off by default. With `CODEMAN_INSTALL_GH=1` / `CODEMAN_INSTALL_AZ=1` as build args in `docker-compose.override.yml`, the server image gets `gh` and/or `az` (with the `azure-devops` extension) wired in as git credential helpers, so after one `gh auth login` or `az login` from a shell session, Add Case → Clone Repo can clone private GitHub and Azure DevOps repositories. `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` do the same for the Docker-case agent image, and only then are the sign-ins copied into new case containers. In multi-user mode a non-admin's clone runs with the credential helpers cleared. This changes `server.Dockerfile`, so Compose deployments need a `Start-Codeman.sh` rebuild rather than an in-app update.
- 13e652e: Run menu: the Gemini, Antigravity and OMP run buttons now show their own colours on every skin; they rendered in Claude blue on all skins except OG (#463). The CLI registry's `accent` values were also corrected to the colours the UI really paints, and a test now guards the stylesheet trap that caused it.
- 13e652e: Terminal: five ways the browser terminal could silently stop being correct are fixed (#431, #464). The browser terminal and the PTY can no longer disagree about their width, which is what produced doubled lines and half-overwritten text ("text gets muffled sometimes"): there is now one function that sizes the terminal, and every resize is answered with the geometry the PTY really holds. A replay clear goes through the terminal's own queue, so bytes written just before it no longer fuse into the next snapshot. A renderer that stops painting after an iOS PWA is backgrounded heals itself instead of needing a reload. Every terminal capture has a deadline that also covers the response body, and a capture that runs out of time during a tab switch falls back to the bounded tail instead of leaving a blank pane. Output lost to a half-open WebSocket is repainted on the next successful open. The service worker's precache list is now generated by the build and its cache is rotated per build, so old releases' assets no longer pile up.
- 13e652e: Docker: new `docker/Update-Codeman.sh` for the major-update path the docs used to describe by hand (#465). It rebuilds the image with `--no-cache` before taking the stack down, clears the build-artefact volumes, refuses to run when another checkout's Compose project already owns the same name, and then hands over to `Start-Codeman.sh`.
- 13e652e: Approvals: a session that is idle only because it is waiting on its own background work (Claude Code's `1 monitor` footer chip, or a Codex background terminal) no longer raises the yellow NEEDS YOU alert or a push (#473, fixes #468). Its idle item is opened already acknowledged, and the tab, the home screens and the rail show a small `watching` badge next to the state instead. The item still exists in the Approvals Inbox, and the TUI's pending count now leaves acknowledged items out.
- b404dac: Maintainer fixes applied while landing this batch:
- Terminal (#431): while another device holds the pane's width, a resize retry no longer re-fits xterm to the container and re-wraps the whole buffer every 30 s, and no longer clears scrollback for a redraw that never comes. The PTY's spawn geometry is now recorded at attach, so `ptyGeometry` never reports a size the PTY never held.
- Terminal (#470): the `TERMINAL DROP` crash-trail line is logged once per recovery window instead of once per dropped frame (which wiped the rest of the trail within a second), and a refresh that died at its fetch deadline is no longer retried.
- Sessions (#467): the resume pin also covers the branch where tmux lost the whole session, the conversation id Codeman reports follows what the relaunch actually resumed, and the test setup strips `CLAUDE_CONFIG_DIR` so the suite stays green for anyone running a separate Claude config dir.
- Sessions (#466): detailed sidebar and rail rows show an `exited` pill instead of `idle`, the exit is announced to screen readers, and the user manual's tab-appearance table lists the new state.
- Approvals (#473): a failed pane capture clears the `watching` badge rather than keeping a stale one (a failure now falls toward an alert, not toward silence), and the header bell's count leaves acknowledged items out, matching the TUI.
- Run menu (#463): the Gemini and Antigravity run buttons no longer render two-tone on phones, Gemini's registry accent matches its tab badge, and a test now guards the stylesheet trap for every run mode.
- Docker (#465): `Update-Codeman.sh` removes exactly the two build-artefact volumes it names instead of every named volume in the project, reports a failing `docker compose` instead of exiting silently, and its docs and comments were corrected. (#472): the multi-user notes say that a non-admin's seeded Docker case also receives the gh/az sign-in when those switches are on.
### Thanks
- @irisitymichaelgrundberg for four PRs in this release: the `watching` badge that stops background work from raising false alerts (#473, from their own report #468), the exited-agent badge (#466) and the dead-pane resume fix (#467), both from their report #446, and the transcript-gutter strip for copied text (#469), a follow-up to their #451.
- @rounakdatta for the terminal resilience work (#431) and the dropped-frame recovery (#470), both from their report #464, and for answering four rounds of review in full.
- @opticon454 for private-repository support in the Docker images (#472), the `Update-Codeman.sh` script (#465) and the run-button colour fix (#463).
- @DodgyBadger for the Android long-press fix (#471), from their own report #360.
## 1.32.0
### Minor Changes
- d47f93a: feat(custom-model): the model picker puts the ready model first
When a custom endpoint has more than one model, the Run menu's picker now promotes one row to the top instead of showing raw discovery order: the model llama-swap reports loaded and ready right now (tagged "Currently loaded", the one a launch attaches to with zero wait), else the model you last launched on that harness and endpoint (tagged "Last used", remembered per device). The endpoint's default keeps its own pill, nothing is ever auto-chosen, and a plain OpenAI-compatible server or an endpoint that does not answer within a second simply keeps the old order. The probe is bounded on the client too, so a GPU box that is off no longer holds the picker closed for five seconds.
- d47f93a: feat(split-pane): view two live sessions side by side
A new Split button in the header (opt-in in App Settings, off by default, desktop only at 1180px and wider) opens a picker and shows a second live session beside the active one: its own terminal, its own WebSocket, and a divider you can drag. When either session ends the view collapses back to one pane, with Pane B promoted to the primary when it is Pane A that ended. Nothing is persisted on purpose in this first cut, so a page reload always returns to a single pane. Pane B is deliberately plainer than the primary pane (no local-echo overlay, CJK input, touch handling or keyboard accessory bar); the design and the v2 boundaries are in discussion #452.
- 72d437a: Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
- d47f93a: feat(mobile): a Compose key for writing prompts on a phone
The agent keyboard bars on phones replace their Paste key with Compose: a real multiline editor with autocorrect and spellcheck, per-session drafts kept in memory only, image attach that never writes into the terminal early, and a Send that delivers the text as one paste followed by Enter, so a long prompt no longer has to be typed blind into the terminal composer. Anything you had already typed into the terminal is picked up into the editor. Shell sessions keep the direct Paste key. This is the manual first slice from #359; the auto-open setting and terminal tap routing are a separate follow-up.
### Patch Changes
- d47f93a: refactor(run-menu): one table-driven launcher for every external CLI
The eight near-identical per-CLI launch functions in the Run menu collapsed into one launcher driven by a table that a CI test keeps in step with the CLI registry, and a second no-id-branching guard now covers the frontend the way the backend guard covers the server. No behaviour change: the refactor was verified byte-identical across 288 launch permutations against the previous code.
- e899af4: Maintainer fixes applied while landing the above. The model picker's promoted row keeps its Default pill (the promotion tag and the default marker are two pills now, and they render as pills in the picker rather than as plain text). The phone composer keeps its bottom gutter on folding devices (the generic fold rule used to erase it), a whitespace-only draft is no longer sent, and its dialog is translated on a zh-CN UI. A split that collapses mid-drag no longer leaves the page stuck in resize-cursor mode, Pane B refuses a session that has no live process, and a burst of refresh frames replays once instead of twice. The `</head>` script injections on the page render use replacer functions, so a CLI label containing `$'` can no longer splice the document into the inline script, and the frontend no-id-branching guard now catches comparisons on any variable name.
- 6ef71ec: ### Thanks
- @timkjr for split-pane sessions (#453): five review rounds turned around in two days, and the pointer-capture edge case measured in a real browser rather than reasoned about.
- @DodgyBadger for the mobile prompt composer (#444), a first contribution that took the scope back down to one slice when asked, and that verified the delivery path against a live tmux pane and a live Claude Code composer instead of trusting the diff.
- @opticon454 for putting the ready model first in the picker (#459) and for collapsing the eight Run-menu launch functions into one (#458), proven byte-identical across 288 launch permutations instead of argued.
## 1.31.0
### Minor Changes
+96 -70
View File
@@ -34,6 +34,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
## CRITICAL: Always Test Before Deploying
@@ -77,7 +78,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.31.0 (must match `package.json`)
**Version**: 1.33.3 (must match `package.json`)
## Project Overview
@@ -112,6 +113,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
@@ -120,7 +123,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
@@ -128,12 +131,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
## Common Gotchas
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
- **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)
- **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`. Tests: `test/advisor-model.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or 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
- **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`
- **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
@@ -170,15 +174,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (27 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
| **Frontend** | `src/web/public/app.js` (~6.9K lines, core) + 34 modules + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
| **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (one module per domain + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
| **Frontend** | `src/web/public/app.js` (core) + the modules listed in the Frontend load order + `sw.js` (+ `voice-pcm-worklet.js`, fetched from JS, not in the load order) | See Frontend section for the load order, which is authoritative |
| **Types** | `src/types/index.ts` (barrel) → domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
**Local packages**: `packages/xterm-zerolag-input/` (local echo overlay, single-source, see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`, hand-tracking overlay source, built via `npm run build:gesture`).
**Config**: `src/config/` — 23 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`.
**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`).
@@ -193,106 +197,118 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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)
**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. ⚠️ **`deepseek` is therefore the one non-claude mode the skill drives like claude** — `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot `idle` report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and `sendwait` asks for `wait:"stop,exit"` rather than the default set, because that set also carries `idle`, which for an external CLI is inferred from output stabilization — on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn with minutes left to run. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case <name>]` / `skill uninstall`, as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note above; ⚠️ plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman`, measured 2026-09-14: neither shadows the other, both work, the docs tell users to pick one route), or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): 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): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent-created case marker** (`src/agent-case-marker.ts`): a case directory `POST /api/quick-start` **creates** for an agent-driven spawn gets a `.codeman-agent-case.json` marker, so the scratch workspaces a long orchestration leaves behind (one per worker, and deleting the session does not remove them) can still be told apart from the user's real projects months later. `GET /api/cases` publishes it as `agentCreated`; `GET /api/cases/agent-created` is the read-only cleanup listing, adding `inUse` (a live session's `workingDir` is that case) and `modifiedAt`; Add Case → Manage badges each one and offers a review-then-delete sweep. The signal is the skill preamble's `X-Codeman-Agent-Origin` header (or an `agentOrigin` body field), falling back to a RESOLVED `parentSessionId` — nothing in the browser UI sets lineage, so a create request naming its spawning session came from an agent by construction, and that fallback is what still labels workers spawned by a stale skill copy. ⚠️ **Only the branch that CREATES the directory may write it.** A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. `POST /api/sessions` takes an existing `workingDir`, so it writes no marker at all, by construction. ⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as *not* agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it. ⚠️ Removal stays on the existing `DELETE /api/cases/:name`, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an `inUse` case outright rather than confirming it away. ⚠️ Marker in the case dir rather than a registry under `~/.codeman`: it survives a wiped data dir or a different instance, is removed by the same `rm -rf` that removes the case (so no stale-entry pruning), and a user who runs `ls -a` can see what labelled their directory. Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0) — a cached copy is version-checked, and forgetting the bump leaves every already-seeded agent sending the old headers. Tests: `test/agent-case-marker.test.ts`, `test/routes/agent-case-marker-routes.test.ts`.
**Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker)
**Agent preamble cache GC**: the §0 preamble seeded per claude session (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`) is now REMOVED with the session (`removeAgentSessionPreamble` from `_doCleanupSession`, `killMux` only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (`pruneAgentSessionPreambles(this.sessions.keys())`, once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and `AGENT_PREAMBLE_MAX_AGE_MS` (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in `test/agent-skill.test.ts`.
**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 (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. 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`, 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.
⚠️ **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)
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck. ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. ⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). ⚠️ 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).
⚠️ **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)
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path). ⚠️ **The unbounded version took a machine down** (2026-07-30): it ran `pgrep -P <pid>` once per node and recursed with no visited set, no depth limit and no node cap, so across ~28 adopted tmux trees the fan-out exploded while each `pgrep` blocked in the WSL kernel reading `/proc/<pid>/cgroup`, ending at ~13,000 `pgrep` processes in D-state, a load average above 13,000, and a machine recoverable only by restarting WSL, which cost every running session. Three properties make that impossible and each has a test: a cycle terminates (a real tree has none, a stale snapshot can still produce one), depth is capped (`PROC_WALK_MAX_DEPTH`), node count is capped (`PROC_WALK_MAX_NODES`). The fourth is structural: the function takes a snapshot and cannot spawn anything at all. ⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots. ⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
**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)
**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)
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve DISPLAY ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs the two call sites that must never disagree (the App Settings checkbox, the chip's visibility). The SAME persisted setting also doubles as the server-side telemetry COLLECTION switch — `readPlanUsageTelemetryEnabled()` (hooks-config.ts) reads it fresh from `settings.json` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), so it applies uniformly to every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) with no per-session state and no per-request field — a Codeman restart cannot silently kill it (there is nothing per-session to lose). ⚠️ An ABSENT key reads as ON (mirroring `readWorkspaceHooksEnabled()`), so the default is resolved by the READER and `GET /api/settings` stays a plain read that never writes: a reconcile write there ran on every page load and could replace an unreadable `settings.json` with a one-key file. ⚠️ A save sends `showPlanUsageLimits` ONLY when it FLIPS the chip on that device (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so a phone saving its font size used to persist `false` and switch collection off for every desktop. Claude data comes from Codeman's marked `statusLine.command` exporter (injected as an EPHEMERAL `claude --settings` CLI flag, never written to disk — see `resolveStatusLineCliCommand`), which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine (it WRAPS it instead — `findEffectiveUserStatusLineCommand`), and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **It revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ **File reads in a remote case are the second ssh surface** (#415, `src/remote-files.ts`): they go through `buildSshConnectionArgs()` as well, a browser-supplied path is only ever a `shellescape`d token, an unreachable host answers 502 (never 404), the size cap uses the REMOTE size, and no remote file is ever copied onto the server's disk — which is why writes, office previews and thumbnails are deliberately unsupported over ssh (the `PUT` guard sits BEFORE the local path validation, or a same-named local directory such as an sshfs mount takes the write). The probe's symlink resolution FAILS CLOSED (a path it cannot canonicalize is a 404, never its own unresolved string: the directory-only fallback let a `notes.txt -> ~/.ssh/id_rsa` link pass containment), and ssh children are BOUNDED by `src/remote-ssh-limiter.ts` plus one batched probe per attachment-history listing, because terminal output in a remote session is written on the remote host and a prompt-injected agent can print hundreds of `codeman://attach` links. The ATTACHMENT routes (a clicked path outside the case dir) go through the same layer, and which host a record is read from follows the SESSION, never the path string. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs in a durable remote `tmux -L codeman-remote`, fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill**; owned ones propagate `kill-session`. Auto-reconnect (`remoteAutoReconnect`, default ON) revives ONLY when `remoteTmuxSessionAlive()` proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (`classifyRemoteAliveExit`), never stdout. ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**; never hand-build one. ⚠️ Remote file reads (`src/remote-files.ts`, attachment routes too) take browser paths only as `shellescape`d tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by `src/remote-ssh-limiter.ts`, and pick the host from the SESSION, never the path; no writes over ssh (the `PUT` guard must precede local path validation). ⚠️ Route remote cases through `POST /api/quick-start`, not `POST /api/sessions`. → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Wake-on-LAN (`remote-wake.ts`)**: an optional `RemoteHost.wakeMac` (Codeman builds the magic packet itself) or `RemoteHost.wakeCommand` (single executable path, run without a shell, takes precedence) lets the INPUT route, `POST /api/sessions/:id/wake`, and the user's own create/attach request (`POST /api/quick-start`, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`) wake a sleeping host instead of writing into a stalled ssh pane. ⚠️ An explicit request — input, the wake button, or the user pressing Run/Attach — and NOTHING else may wake: the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery and `cron-service.ts` have no access to the registry (a wake there would re-wake the host seconds after every suspend, and the create wake is wired in the route rather than the shared session service for exactly that reason), which `test/remote-wake.test.ts` asserts as two wiring guards — the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. `GET /api/sessions/:id/reachability` merely probes and never wakes. Detection is a throttled bare TCP probe — deliberately no `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. ⚠️ A host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option is reachability-UNKNOWN (`isProbeable()`): the probe connects to `host:port`, which such a host does not answer even while ssh works, so the registry never buffers for it, never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false` — the banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids). Input arriving during a wake is buffered (a chunk over 4 KB is dropped whole, never delivered as a fragment; the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell) and flushed in order after `reattachRemote()` with `fromUser` — a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later; send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything — the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The wake fields are re-read from `remote-hosts.json` on recovery and, throttled+cached via `RemoteWakeDeps.resolveRemote`, for a LIVE session, since the persisted `remote` snapshot never sees a field added later. UI: the amber `#hostWakeBanner` (`host-wake-ui.js`) with Wake / "Configure WoL" → `#wakeConfigModal`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
**Wake-on-LAN (`remote-wake.ts`)**: optional `RemoteHost.wakeMac` (magic packet) or `RemoteHost.wakeCommand` (single executable, no shell, takes precedence) lets HTTP input, `POST /api/sessions/:id/wake` and the user's create/attach (`ensureHostAwake`) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, `handleRemoteSessionDropped`, boot recovery or `cron-service.ts`, and `GET /api/sessions/:id/reachability` must never wake. ⚠️ Detection is a throttled bare TCP probe; never add `ServerAliveInterval`, and a `jumpHost`/`socksProxy`/`ProxyCommand` host is reachability-UNKNOWN (`isProbeable()`): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (`host-wake-ui.js`) must not promise queued input. Waiting requests use the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`. → [architecture-invariants#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases)
**Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ A case may instead **ADOPT** a container the user already runs (`DockerCase.owned === false`, mirror of remote-SSH's `owned:false`): Codeman only `exec`s into it and never creates, starts, stops, restarts or removes it, so a missing or stopped container FAILS CLOSED with an actionable message instead of being fixed. Absent = owned, so existing cases are byte-identical. ⚠️ An ADOPTED container may back SEVERAL cases at different in-container directories (`classifyAdoptContainerConflict` in `docker-hosts.ts`: an exact twin on the same container AND directory is refused, an owned container still backs exactly one case, and a container another user adopted is refused), which is what the Add Case panel's "copy an existing case" picker relies on; the wire carries `CaseInfo.docker.owned` ONLY when false, so the picker tests `=== false`, never truthiness. The guarantee is enforced at four independent layers because it cannot be observed by using the feature: `buildDockerStopCommand`/`buildDockerRemoveCommand` throw during pure STRING CONSTRUCTION, `removeDockerContainer` refuses again, drift reports "none" (an adopted container carries no `codeman.confighash` label, so a real comparison would 409 the launch forever), and the boot reaper skips it. ⚠️ Two lifecycle touches the original design missed and that are easy to re-introduce: the full-image export `docker commit`s the container (refused for an adopted case) and the workspace export `docker pause`s it first (skipped — it freezes the owner's processes for the length of the tar). ⚠️ `owned` is applied AFTER `dockerConfigHash`, which takes an explicit field list, or every pre-existing case would trip the drift gate at once. ⚠️ Run modes for a container case come from the CONTAINER (`availableModes`, live-probed): gating the run menu on HOST CLIs (#201) is right for local sessions and wrong here, since a host with no `claude` may run a container that ships one. ⚠️ **A failed probe means opposite things per ownership** — for an ADOPTED case it is a fault worth reporting, for an OWNED one it is the NORMAL state before the first session (the launch chain creates the container), so treating it as a fault hid every agent mode on every freshly linked Docker case behind "start it yourself first". That is why `CaseInfo.docker.owned` is on the wire. ⚠️ Claude is launched WITHOUT `--dangerously-skip-permissions` when the container's exec user is root (Claude Code refuses the flag as root and the refusal is visible only inside the container); which flag to drop is a per-CLI fact, so it is the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is **admin-only in multi-user mode**, unlike `docker-link`: linking creates OUR container, whose one bind mount `isWorkingDirAllowed` has already confined, while an adopted container's mounts belong to its owner and one mounting `/` hands the adopter the host. The same reasoning admin-gates the container listing and the in-container directory browser; the preflight instead admits a non-admin for a container already linked to a case they own, because the run menu probes it for every docker case. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for. ⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing. `Start-Codeman.sh` pre-creates both `CODEMAN_APPDATA_PATH` and `CODEMAN_CASES_PATH` on the host before `up`, which is what keeps the daemon from ever having to materialise either as root in the first place; the container ALSO starts as root (`cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against the base `cap_drop: ALL`; `test/docker-entrypoint.test.ts` pins that list) so `docker/entrypoint.sh` can correct a bind source that turns up root-owned anyway (a restored backup, a cleared directory, plain `docker compose up` run without the script) before dropping to `PUID:PGID` via `setpriv` — a directory owned by neither root nor `PUID:PGID` is never re-owned, since that ownership is not this container's to reassign; it is PROBED for writability as the runtime account (`setpriv ... test -w`, so ACLs, group-writable trees and CIFS/NFS mounts pass) and refused with a message naming path, owner and PUID:PGID if that fails. ⚠️ `KILL` is in that list for tini, not the entrypoint: `init: true` keeps tini as root while the server runs as PUID, and without CAP_KILL its SIGTERM forward fails and the server is SIGKILLed on every `compose down`/`restart` instead of flushing state. ⚠️ `/opt/codeman-cli` (the runtime-owned CLI prefix) is APPENDED to `PATH`, never prepended, and the entrypoint pins its own `PATH` to the system dirs: the root part of the start resolves `setpriv` by bare name, and a prefix ahead of `/usr/bin` let a planted `setpriv` run as uid 0 (measured). `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update below and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. `docs/docker-compose.md` + `docker/README.md` (user guides)
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. **No code outside `stock.ts` may branch on a CLI id**; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (`profiles.ts`), and `test/cli-registry-no-id-branching.test.ts` fails the build if an id check reappears — it matches `===`, `!==`, `case '<id>':` and `[...].includes(mode)`, because an earlier `===`-only version let 36 negated branches survive the conversion (including a seven-mode Ralph chain whose own comment asked the next person to keep it in step with `isExternalCliMode()` by hand). ⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. ⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug. ⚠️ Two capability fields carry a REGEX from config (`discovery.version.regex` and `capabilities.workDetect.workingLine`) and both must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path. ⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression. ⚠️ Six fields are DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured, so re-measure before wiring one up; the list is pinned so it cannot quietly grow. Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden. ⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). → `docs/cli-registry.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) 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/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. ⚠️ **Work detection is no longer part of that gate**: it is per-CLI `capabilities.workDetect` data (see the ❯ note above), so a CLI that declares its own glyph and working line gets the same screen-probed idle confirmation claude gets, and one that declares neither keeps the output-stabilization behaviour. All eight **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/<name>` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half Line truncated
**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)
**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**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs.
**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)
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ **Two launch paths, chosen by mechanism, not preference — see the second paragraph below for why**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privileLine truncated
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Everything below landed after the initial backend + picker cut, each confirmed live against a real llama-swap deployment.** ⚠️ **llama-swap runs one model at a time, and switching can disrupt ANOTHER live session** — before applying, both apply routes call llama-swap's own `GET /running` (feature-detected via `getLlamaSwapStatus()`, `custom-model-routes.ts`; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is simply never checked). If a different model is loaded and ready AND another live session's own selection is using it, the apply returns `{requiresConfirmation, currentlyLoadedModel, affectedSessions}` instead of switching silently; retrying with `confirmedSwap: true` skips the check, and switching with nothing else affected proceeds immediately. ⚠️ **The swap question and the context-floor question below have SEPARATE flags** (`confirmedSwap`, `confirmedContext`), because the context check runs first and while both shared one `confirmed` a user who clicked past a too-small context silently consented to evicting another session's model too; the legacy `confirmed` still means both, since it shipped in the HTTP-API-only cut. llama-swap also has no dedicated "switch model" endpoint — the only thing that actually starts a swap is a real inference request naming the model (confirmed live: applying a selection alone never reached llama-swap's own logs, since nothing had asked it to load anything) — so both routes also fire `triggerLlamaSwapLoad()`, a fire-and-forget `POST <baseUrl>/v1/chat/completions` with `max_tokens: 1`, whenever the target model isn't already loaded and ready. ⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. ⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. ⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasLine truncated
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF; #376): a placeholder tab (`w3-myapp`) takes its first real prompt as a title, in the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`) that `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip and that `_nextCaseSessionStartNumber()` still counts, so the case identity and the `w<n>` counter survive. Ownership is the tri-state `SessionState.nameSource`: `placeholder` (Codeman's own `w<n>-<case>` or no name, inferred by `isGeneratedSessionName()` when a persisted state predates the field), `auto` (titled once), `manual` (the `name` setter, i.e. `PUT /api/sessions/:id/name`, which auto-naming never touches again). ⚠️ **First prompt means the FIRST**: `applyAutoName()` flips a placeholder to `auto` whether or not the string changed, so a later "1" cannot rename the tab; a prompt that yields no title (`/clear`, a `!` shell escape) leaves the session eligible for the next one. ⚠️ **Only user-originated input counts** (`SessionWriteOptions.fromUser`, set by the browser WS path and `POST /api/sessions/:id/input` ONLY, so a forgotten flag on a new path fails toward not naming): Ralph kick-starts, respawn `/clear`s, cron launches, approval answers and the trust-dialog keys write through the same `write()`/`writeViaMux()` and used to name every Ralph tab "Read @ralph_prompt.md…". A `startMode: 'shell'` CLI never feeds the tracker (a capability, not an id check; a shell tab was renamed after every `ls`), and the send-key route's Shift+Enter line feed bypasses the session entirely, so it calls `trackUserInput()` or the two lines join with no separator. ⚠️ **The tracker (`session-auto-name.ts`, pure) sits on the raw keystroke stream**, so every key has an explicit rule: a bare Esc is resolved at the END of the chunk it arrives in (it used to stay in escape mode and eat the next prompt's first character, or a whole CJK prompt); SGR mouse reports, Tab, cursor keys and Shift+Tab leave the draft alone (a wheel tick mid-word used to drop the first half); Up/Down and Ctrl+P/N/R TAINT the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines are newlines IN the composer, never Enter. The title is the first sentence past a minimum length ("e.g. fix this now" is not "e.g."), capped at 72 code points, and the composed name honours `MAX_SESSION_NAME_LENGTH`. The listener (`session-listener-wiring.ts`) checks eligibility BEFORE reading the setting, so an already-named session costs no settings read per prompt. Opt-in because the prompt lands in the tab name, `mux-sessions.json`, every `session:updated` and `/api/search` (Read My Mind keeps prompts 0600 for the same reason). Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.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`: 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)
**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.
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). ⚠️ **Transcript history is THREE stores, not one**, because each CLI keeps its conversations in its own: Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions` and codex's `~/.codex/sessions` (#386). Rows fold into their owning session via the `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice; that field is named for Claude and carries whatever id the CLI names its conversation with, which for every non-Claude row diverges from the Codeman id by construction. ⚠️ **`resumeId` is set by a SCANNER row only, never by a live session**, and that is what makes it safe to resume on: a row carrying one is a conversation already on disk, so `resumeHistorySession()` sends `codexConfig.resumeSessionId` and a row without one is a genuinely fresh session. Every surface that re-projects these rows has to carry the field through, the phone overview included, or a tap on that surface silently starts a second conversation. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. ⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. ⚠️ The layout does not replace `PUT /api/session-order`, it PROJECTS onto it: `tab-layout-legacy-order.ts` is the pure translation both ways (`putLegacyOrder()` recomposes a global order from the owner's groups), so changing one side without the other silently desyncs the tab strip from the stored layout. ⚠️ **Reconciliation is gated on a SUCCESSFUL restore** (`markRestorationComplete` / `markRestorationFailed` / `markRestorationSkipped`, plus `assertDeletionReady()`): pruning refs against a session list that failed to load would delete live tabs, so a failed restore must leave the layout untouched. `PUT` takes exactly `{baseVersion, layout}` (any other key shape is a validation error), answers a stale `baseVersion` with the current layout rather than clobbering, and is capped at 128 KiB. Broadcasts `tab:layoutChanged`, owner-routed via `deriveTabLayoutSseHint`.
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted` (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
**Reboot restore** (#411/#442, `src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): a host reboot takes the tmux server with it, so every pane dies and the board comes up empty with no explanation. At boot Codeman works out which sessions that reboot destroyed, holds the plan IN MEMORY (no new state file, and a server restart simply drops the offer), and the banner asks. ⚠️ **The heuristic decides whether to ASK, never whether to act**: two signals have to agree (the socket holds no panes at all while state still lists sessions, AND the host booted after the newest persisted activity), and a wrong yes costs one dismissable line rather than N CLI processes nobody asked for. ⚠️ Rebuilding is TAKE-then-build: entries leave the plan synchronously before the first `await` and the route is single-flighted per owner, so a double-click or a second device cannot put two panes on one conversation. Anything that never became a pane goes BACK on offer, with one deliberate exception, `already-live`, which unlike a missing workspace or a withdrawn grant cannot stop being true. ⚠️ Three things are re-checked at click time rather than trusted from boot (the owner's privilege grant, the workspace still being on disk, and the conversation not already being live), and the already-live sets are read FRESH per iteration rather than snapshotted: the loop awaits a real `startInteractive()` per entry, so a snapshot taken before it is tens of seconds stale by the tenth entry and would miss a conversation the user resumed by hand in that window. The confinement re-check is keyed on the entry's OWNER, never the caller, or an admin spending another user's entry is waved through by `isWorkingDirAllowed`. ⚠️ A rebuilt session comes back **attached, idle and disarmed**: the pane is NEW, so terminal scrollback is gone (the banner says so) while the conversation continues, respawn controllers and Ralph loops are never re-armed, and `reapplyPersistedSessionState(..., { rearmAutoResumeSchedule: false })` keeps auto-resume ENABLED but drops the pre-reboot `autoResumeAt` stamp, or one click has every restored session type `continue` into itself a minute later, unattended. That option exists only for this path; a Codeman restart still re-arms, because the limit footer will not reprint on its own. ⚠️ The rebuild passes `nameSource` through, or the constructor re-infers it from the name and a hand-renamed session shaped like `w<n>-<case>` comes back as `placeholder` for auto-naming to overwrite. ⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up. ⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's `.claude-images`. ⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`.
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal ALONE is restricted to `idle` items; a permission/question item gets the pane-VERIFIED variant on that same signal (`resolveIfDialogGone()` → `verifyStillAnswerable()`), so the heuristic only decides when to LOOK and the screen decides the outcome. That is what clears a dialog answered in the terminal mid-turn; the other definitive signals are `stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede and the 12h TTL. ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). ⚠️ **`applyCapture()` is therefore ADD-ONLY for `options`**: a re-capture that parses nothing must never erase a parse an earlier one found. Claude Code delays the Notification hook behind the dialog (measured 6s, documented ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered; clearing the field there made the item permanently unsweepable, because `verifyStillAnswerable()` reads a MISSING `options` as "we never could read this dialog" and keeps such items answerable by design. The red "needs you" then survived every sweep AND every page reload, went away only on `stop` (2026-08-20: a confirmed question left a tab flowing red for ~8 minutes while the turn ran on), and the stale card still accepted an answer, typing a bare `1` into a composer with no dialog under it. Pinned by `test/approval-inbox.test.ts`. ⚠️ A frame that parses no options is CONCLUSIVE in exactly two cases, and the second one closes the late-hook hole: the item once parsed options (they cannot vanish while the dialog is up), or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist — measured on v2.1.237, a live-dialog frame carries neither the `… (13s` timer NOR the `esc to interrupt` footer, which the dialog replaces with `Enter to select · ↑/↓ to navigate · Esc to cancel`. Anything else stays answerable, so an unreadable capture Line truncated
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory, claude-only queue fed by `/api/hook-event`, at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses. ⚠️ **Claude Code's voice mode itself is unusable here**: it opens the HOST's microphone (`sox`/`arecord`), and the CLI runs in a headless tmux pane while the human is in a browser elsewhere. So Codeman captures in the browser and borrows only the backend. Audio goes browser → Codeman → Anthropic (`src/web/voice-stream.ts`): the OAuth token never reaches the page, and the browser only sends PCM and receives text. ⚠️ Credentials are **read-only** (`src/claude-credentials.ts`) and Codeman never refreshes them — a refresh rotates the refresh token and could sign the user out of their own CLI; an elapsed token reports `expired` instead. ⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**. ⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
**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)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ **A `full=1` capture ENDS with a cursor move back to the pane's own caret position**, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not `CUP`: absolute row addressing is only right while the browser's rows equal the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (`isFullCapture`), never on `?full=1` alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. ⚠️ **A visible capture now REPORTS the geometry it was taken at** (`captureCols`/`captureRows`, #435), because a frame built for a pane taller or wider than the browser is damaged two ways at once (overflow rows clamp onto the last line; a narrower browser wraps every painted row) and nothing in the response used to say so. Both fields are ABSENT when no frame was positioned, so every consumer tests `Number.isFinite`, never truthiness: a `display-message` cursor query that fails makes `capturePaneBuffer` return the raw capture while the route still labels it `mux-visible`. The comparison runs on `mux-visible` ONLY, the replay is capped at one attempt, and a pane that cannot be sized to fit latches in `_geometryRetryUseless` so it is diagnosed once per session rather than on every tab switch. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**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 and drop recovery use a bounded 1 MiB `?tail=`, and 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 (`SplitTerminalPane._pullHistory`, terminal-split.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 touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`.
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which appends a hidden `contenteditable` trap, focuses it, and reads the clipboard out of the paste event that lands there. Images upload and their saved paths are typed into the session; text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event.** Two routes deliver one for a single keypress and Firefox fires both: `document.execCommand('paste')` dispatches a trusted event and still returns `false`, because the trap cancels it, while Chromium refuses that command and dispatches nothing; separately, the keydown's own default action delivers a paste to the now-focused trap, because returning `false` from the custom key handler never cancels the DOM event (see smart copy above). Measured on a live install: Firefox two events per keypress, Chromium and WebKit one. Handling both wrote the clipboard to the PTY twice, and right-click → Paste stayed correct because it carries no keydown. ⚠️ Removing the `execCommand('paste')` call would also end the doubling, and all three engines still deliver one event without it, but it stays for the mobile engines a desktop measurement cannot reach: where a browser aims the key's default action at the element focused when the keydown began, the command is the only route into the trap, and the trap is the only place image blobs are read. The one-shot flag lives on the trap rather than on a browser check, so any count produces one insert. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](docs/architecture-invariants.md#auto-copy-copy-on-select)
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_logScrollRouting()` prints the routing decision and its inputs once per session — read it 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** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. ⚠️ **The Compose deployment is the one supervisor that does NOT outlive the restart**: there the restart IS the container exiting (`restart: unless-stopped` relaunches it), which kills the script too — safe only because the terminal `restarting` marker is written BEFORE the kill, so nothing may be appended after it. Two config facts make it work at all and both are load-bearing: the repo is a HOST BIND MOUNT over `/opt/codeman` (a pull into the baked image copy would land in the writable layer and be silently discarded by the next `up`), and the runtime image keeps devDependencies + a build toolchain (`npm run build` is tsc+esbuild, and node-pty has no Linux prebuild), which is why `npm prune --omit=dev` is gone and the updater passes `--include=dev` against `NODE_ENV=production`. ⚠️ An in-place container update applies CODE ONLY — a restart reuses the existing image and config — so `evaluateEnvironmentGate()` REFUSES a release that changes `server.Dockerfile`/`docker-compose.yaml` (sha256 vs the baseline `Start-Codeman.sh` writes to `docker-env-applied.json` on every start) or adds `.env.example` keys the user's `.env` lacks, and refuses when the restart policy would not bring the container back. That third check exists because **Compose resolves an unset `${VAR}` to the EMPTY STRING and starts anyway**, so a new required setting otherwise arrives as a silently blank env var. Every unknown fails OPEN in the gate (no baseline, unreadable `.env`, no socket): failing closed would permanently block containers created before the fingerprint file existed. ⚠️ The KILL does not: the server exits only when `--restart-by-exit 1` was passed, i.e. the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (set ONLY there, since that file is what sets `restart: unless-stopped`; the image ENV deliberately does not) or the daemon reported an auto-restart policy; otherwise the build lands as `completed-needs-manual-restart`, because exiting blind takes a `docker run` container with no restart policy down with no UI left to recover it. The gate is re-evaluated on `POST /api/system/update`, so hiding the button is UX, not the control. ⚠️ The four global agent CLIs in `server.Dockerfile` are PINNED on purpose — unpinned, a user's CLI versions are a function of when their image was built rather than of any commit, which is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already catches. `test/docker-compose-env-parity.test.ts` is the merge-side guard (every compose `${VAR}` ↔ an `.env.example` entry). → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ 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. 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)
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress: **(server ingress)** `stripBasePath()` runs inside Fastify's `rewriteUrl` so ALL routes stay declared prefix-agnostic (`/api/...`, `/ws/...`) — and a request arriving WITHOUT the prefix (hooks, health checks, docker bridge, all hitting the raw port) is left untouched, so the server answers at both; **(server egress)** one `onSend` hook prepends the base to every root-absolute `Location` header, covering all redirects; **(HTML)** `renderIndexHtml` rewrites the shipped `<base href="/">` to the mount and injects `window.__CODEMAN_BASE__` — the template's asset refs are all RELATIVE so `<base>` handles them for free; **(frontend runtime URLs)** root-absolute URLs ignore `<base>`, so `CodemanBase.url()` (constants.js) is the route builder, applied transparently by a `fetch` wrapper and explicitly at the few EventSource/WebSocket/`window.open`/`<img|iframe|a>`-src sites; **(sw.js/manifest)** the worker derives its base from `self.location`, the manifest uses relative `start_url`/`scope`; **(web-tab proxy)** `proxyPrefixFor(cap, basePath)` is the single base-aware root that cascades to the injected `<base>`, root-absolute HTML rewrites, the `runtimeUrlShim`, `Set-Cookie` Path and `Location` rebasing — while the INGRESS parsers (`capabilityFromProxyPath`, `resolveUpstreamUrl`) stay base-agnostic because `rewriteUrl` strips the prefix before routing, and `capabilityFromReferer(referer, basePath)` strips it from the browser-supplied Referer. ⚠️ `--base-url` rides the daemon relaunch via `buildWebArgs` and the service unit via `resolveServicePlan`. Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.ts`; HTML injection in `test/render-index-html.test.ts`.
**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)
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
**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)
**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 the response viewer's `_linkifyFilePaths()`; a fresh instance per call, since `lastIndex` is per-object state. The chat linkifier walks TEXT NODES with DOM APIs (the source is model output; never rebuild sanitized markup as a string) and skips subtrees already inside an `<a>`. ⚠️ **An out-of-workspace path is served through the ATTACHMENT routes, not the file routes** — `file-content`/`file-raw` are workspace-confined and 404 exactly the paths agents print most (a `/tmp` capture, Claude's scratchpad), so `openFilePreview()` registers such a path via `POST /api/sessions/:id/attachments` with **`notify: false`** (suppresses only the `attachment:detected` broadcast — same guard, same routes; without it every click also popped a card announcing the file already on screen) and renders by id. The click is an explicit action on the explicit, Origin-guarded route, which is what distinguishes it from the force-confined magic-link scanner. ⚠️ **Media extensions are single-sourced** (`VIDEO_ATTACHMENT_EXTENSIONS`/`AUDIO_ATTACHMENT_EXTENSIONS` in `attachment-registry.ts`, imported by `file-content`'s classification) so a clip plays the same in or out of the workspace; a player needs all THREE of allowlist + a real `MIME_TYPES` entry (octet-stream renders a dead player) + the range-aware body. ⚠️ **`TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS`** (never a second list): if the viewer would edit it inside the workspace, it can be read outside. Widening READ must never widen RUN, so `html`/`htm` joined `svg` in `serveRawFile`'s download-only branch, other text goes out as inert `text/plain`+`nosniff`, and `~/.codeman*/state.json` joined `isSensitivePath` (it persists `envOverrides`, which can hold `GEMINI_API_KEY`). ⚠️ The terminal sends an **out-of-workspace** path to the preview instead of the log viewer (that one spawns `tail -f` and reaches only workspace + `/var/log` + `~/logs`); in-workspace text keeps the tail viewer and `file-stream-manager`'s allowlist is untouched. The image-watcher keeps its own narrow detection list, so none of this cards every file an agent writes. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). 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`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree. ⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested. ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. A query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: only an IN-WORKSPACE `.md` path clicked in the TERMINAL keeps the tail viewer (live follow); the Files panel, chat paths, out-of-workspace terminal paths and attachment cards all reach `openFilePreview()` and render it. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ **The size cap on all three is a sanity bound, not memory protection** (`MAX_FILE_DOWNLOAD_BYTES` in `config/buffer-limits.ts`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited): the bodies stream, so size costs a read stream and not RSS (measured: a 600MB download moved peak RSS by ~37MB). Its predecessor was a hardcoded 50MB whose comment still said "prevent memory exhaustion" long after the `readFile()` it described was replaced by `sendFileBody()`, so all it did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file, and now shares `sendFileBody()` with the other two. ⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file. ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` 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)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-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 agent sessions. **NOT a `SessionMode` of its own** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. ⚠️ **Egress guard**: link-local and cloud-metadata targets (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, the Azure/Alibaba fixed addresses, `metadata.google.internal`) are refused at save time AND on the RESOLVED address at connect time (`webview-egress-policy.ts`, pure, plus `webview-egress.ts`: a `lookup` hook on the undici Agent behind `webviewFetch()` and on the `ws` client). An IP literal never reaches a lookup hook (`net.connect` skips DNS for it), so the synchronous hostname check at each connect site is NOT redundant. Loopback and RFC1918 stay allowed on purpose: a `localhost` Grafana is the feature. The proxy uses the `undici` PACKAGE's own `fetch` + `Agent`, never Node's global fetch with a foreign dispatcher (Node bundles its own copy; a protocol mismatch fails silently). ⚠️ **A loopback link in agent output opens as a proxied web tab** (`openLinkThroughWebTabIfLoopback` in webview-tabs.js, called from the terminal link provider and the response viewer): a phone cannot reach the box's `localhost:5173`, so the tap routes through Codeman's origin instead, reusing a saved same-origin dashboard or saving one under `host:port`. Two rules that look cosmetic and are not. **`*.localhost` is deliberately NOT in the auto-route set** even though it IS loopback to a browser: the link source is agent-written terminal output and prompt-injectable, every other member of that set is an address literal that can only mean this box, and a `*.localhost` DNS name is not one (a resolver with a search domain retries `evil.localhost` as `evil.localhost.<search domain>`, which an attacker can control, turning agent output plus one tap into a persisted server-side fetch of an agent-chosen origin). A user who really runs `api.localhost` saves it by hand, which is an explicit action. The PAGE-side test is deliberately broader (`isOnBoxHostname`), since a false positive there only declines to proxy. And **`this.webviews` being set does NOT mean it is loaded**: `initWebviews()` assigns a truthy EMPTY map synchronously and only then awaits the list, so a tap during page load must join the in-flight refresh (`_webviewsRefresh`/`_webviewsLoaded`) or it finds nothing to reuse and POSTs a duplicate record for an origin that already exists. Capabilities are revoked on logout / admin logout / user deletion (`revokeOwner`, which had NO caller for two releases while its docstring said otherwise), and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability-bearing URL to a third party. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**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`
@@ -304,45 +320,55 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `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 trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `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 trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Test: `test/mobile-tab-tap-zones.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, App Settings → Appearance → Tabs, default `header`; per-device policy — it IS in `SettingsUpdateSchema` and persists server-side, but `displayKeys` makes a device keep its own value): with many sessions the horizontal strip stops being scannable, so the list can move into a vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). ⚠️ **There is ONE `#sessionTabs` element and it is MOVED between hosts** (`#sessionTabsHost` in the header, `#sessionSidebarList` in the aside, `#tabRail` for the vertical rail below), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ It sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes. ⚠️ **The vertical tab rail** (`tabOrientation`/`tabRailWidth`/`sessionSidebarFontSize`, all per-device display keys that ARE in the schema, like `sessionListLayout`) is a SECOND vertical list next to the sidebar: the orientation setting is silently ignored while the sidebar layout is chosen, desktop/tablet only (`resolveTabOrientation` forces horizontal on mobile), resizable via `tab-rail-resize.js` (which owns terminal refits during the drag). ⚠️ **Detailed rows are a property of a vertical LIST, not of one surface** (`sessionListLayout: 'sidebar-rich'` for the sidebar, `tabRailDetail: 'rich'|'simple'` for the rail, rail default **rich**): both draw the home screen's per-session line (`created 3d ago · working 12m`) plus a status pill, from the SAME row model (`_sidebarRichRow`/`_sidebarRichMetaHTML` in app.js, classified by `_mobileOverviewState`/`_mobileOverviewSince`), and the render paths ask ONE gate, `isRichTabRows()` (= `isSessionSidebarRich() || isTabRailRich()`). ⚠️ Detail rides on its own attribute (`data-sidebar-detail` / `data-tab-rail-detail`) so every existing `[data-session-list="sidebar"]` / `[data-tab-orientation='vertical']` rule keeps matching both variants untouched; a flip of detail ALONE still needs a full render (the stamps line is emitted by the row template, not toggled by CSS) and must re-run `applyTabWrapSettings()`, which owns the folder line and is now rail-aware. ⚠️ The rich CSS rules carry a rail twin as a COMMA-GROUPED selector, never `:is()` (an `:is()` list takes its most specific argument, which would lift the sidebar arm from (0,3,1) to the rail's (0,5,1)). ⚠️ Width is the whole reason there are thresholds: the rich sidebar is 300px (`--sidebar-width-rich`) and a rail that has never been sized defaults to **320** (`RICH_DEFAULT_WIDTH`, the existing Wide preset) instead of 256, because at 256 the stamps line ellipsizes mid-word; a user-narrowed rail drops the created stamp below 288 (`tab-rail-tight`, CSS only) and drops rich rows entirely below 240 (`tab-rail-compact`, which re-renders). A stored width is never overridden. ⚠️ Only detailed rows carry stamps that go stale with no event behind them, so `_startSidebarRichClock()` (20s, rewrites text in place — a re-render would restart every row's animation) must be armed and disarmed by BOTH `applySessionListLayout()` and `applyTabOrientation()`. ⚠️ **Axis decisions must use `_isVerticalTabList()`** (sidebar OR rail), never `isSessionSidebarActive()` alone: the rail leaves `data-session-list` at `header`, and the sidebar-only predicate shipped four rail bugs at once (drag insertion side read from clientX, active tab never scrolled into view, floating windows anchored below tabs instead of beside them, connector redraws skipped on rail scroll). The pre-paint script stamps `data-tab-orientation` (+ `--tab-rail-width`) like it stamps the sidebar keys, or vertical mode flashes through the header strip; the name font size defaults to 12px, the sidebar's historical size, so untouched installs are never restyled. ⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the aside is an off-canvas overlay rather than a docked rail, and a closed drawer keeps `display: flex`, so it is marked `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) or its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable. The desktop home rail (`home-sessions.js`) dLine truncated
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **overview order** (see below), and each carries a **created** stamp plus the **state duration** the order is computed from (`created 3d ago · working 12m`, word and anchor from `_mobileOverviewSince()` so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now". ⚠️ The number badge is the **Alt+1..9 index**, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure + unit-tested in `test/session-overview-order.test.ts`): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is `needs` → `error` → `waiting` → `working` → `idle` → `done`, and ⚠️ **the tiebreak flips direction halfway down**: states a session is still IN sort **oldest-first** (blocked longest / running longest = most urgent), states it has STOPPED in sort **newest-first** (the session that just went quiet is the one you came back for). ⚠️ The running group keys off **`lastSubmitAt`** (the pane's last Enter), never `lastActivityAt`: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it. ⚠️ A **0 stamp means "unknown", not "the epoch"**, and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (`orderIndex`), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](docs/architecture-invariants.md#home-screen-session-order)
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. ⚠️ **The gate tests the CLEANED selection, not the raw one** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` reads the live terminal): xterm returns whole screen ROWS and trims only never-written cells, so the spaces a full-screen TUI paints across the rest of a row are content and reach the clipboard (138 of them per line, measured in a 282-column pane). The clean drops each line's TRAILING run and nothing else. ⚠️ **A shared LEADING indent is deliberately NOT stripped**, and that is a decision, not a gap: it was built, measured and dropped before #451 merged, because it fired on 73% of real non-TUI text (401,445 windows sampled) and no width threshold separates a TUI margin from content (a claude pane's own margins are 2 and 5 columns; the commonest non-TUI shared run is 4). Do not re-add it without reading the rule in architecture-invariants. ⚠️ Alt+drag COLUMN selections are returned untouched (`_activeSelectionMode === 3`), and the padding-only clear is FEEDBACK rather than interrupt protection, since a padding-only selection now cleans to `''` and falls through to the PTY on its own. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): the `set-*` language (left rail, groups of rows, control pinned right) is shared by all three modals through ONE `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in styles.css: an `:is()` list takes its most specific argument's specificity, so every rule keeps the id weight it had and nothing downstream shifts. **App Settings** is a rail that is a **table of contents over ONE scrolling document**, not a tab switcher: every section stays mounted (`.set-section`, ids `settings-updates|terminal|layout|appearance|models|clis|notifications|voice|shortcuts|system`, in that order, the version and the updater leading and the rest of the system settings tailing), and `switchSettingsTab(id)` keeps its historical name but SCROLLS instead of hiding. **Session Options** and **Add Case** use the same surface with a rail that really SWITCHES (`switchOptionsTab` / `switchCaseModalTab` show one `.set-section` and `.hidden` the rest, since Summary owns its own scroller, Respawn is long, and Add Case is six independent forms). ⚠️ They also take a deliberate **size-up** that App Settings does not (900px shell, 236px rail, `height:auto` between `min(560px,80vh)` and 88vh, vs App Settings' tight 760×620): they are short task panels, not a document you scan, and at scanning density they read as a few fields marooned in an empty frame. Those per-modal blocks are the design, not drift. Phones (≤860px) give App Settings the sticky `#appSettingsJump` pill and give the other two a horizontal rail strip, which neither has a pill for. ⚠️ The Session Options rail entry labelled **Session** still keys off `context` (`data-tab="context"`, `#context-tab`, `switchOptionsTab('context')`), the rename is label-only. Add Case keeps its legacy `.form-row` markup (six panels of it, every id read back by session-ui.js) and is mapped onto the look by an adapter block scoped to `#createCaseModal .set-doc`. Do not restructure those forms just to reach the row classes. ⚠️ That adapter's `summary { display:flex }` **kills the native disclosure triangle**, so every `<details>` there needs the explicit `.set-adv-chev` and both marker suppressions (`list-style` + `::-webkit-details-marker`); without it five collapsed blocks render as plain headings nobody clicks. ⚠️ **The load/save contract is `getElementById` by id**: `openAppSettings()`/`saveAppSettings()`/`openSessionOptions()` read every control by a fixed id, so moving a control between sections is free but renaming or dropping one silently stops it loading or saving. Static guards: `test/app-settings-structure.test.ts` + `test/session-options-structure.test.ts` (rail↔section pairing, one-visible-section, the `data-claude-only` entries external CLIs drop). ⚠️ Model cards (`#appSettingsModelCards`) and the effort segment are **views over hidden `<select>`s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. ⚠️ `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` are RETIRED: no modal uses them and their CSS is deleted, and a reappearance means a modal drifted off the shared surface. ⚠️ The **Header & Panels live preview** is a scale model rebuilt from the chips (`_syncLayoutPreview`); it owns NO icons, it CLONES `.set-chip-ico` out of the chip, so each icon has exactly one copy in index.html. A chip joins it via `data-preview` (slot) + `data-preview-order`, or `data-preview-text` for readouts that are not buttons. Its frame is painted from skin tokens only (hardcoded black alphas turned it into a grey slab on the light skins) and is `data-i18n-skip`. ⚠️ In Session Options → Respawn, auto-resume is a `.set-callout` whose `<label>` **wraps its own switch with no `for=`** (nesting associates them; the label+`for` pair has historically double-fired), and the cycle steps are real checkboxes (`.set-checks`), not chips. ⚠️ `admin-ui.js` injects the multi-user Users entry into `.set-rail-items` + `.set-doc`, so those hooks must survive any restructure. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no colour change, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves both slots, each against **its own** xterm default, so an unset bold weight can never inherit `normal`. ⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range). ⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates). ⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](docs/architecture-invariants.md#terminal-font-weight)
**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on `<html>`, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **A visual-viewport resize that changes the WIDTH is the device changing shape** (a rotation, or a foldable opening or closing) **and is never the virtual keyboard**, which only ever takes height. `handleViewportResize()` read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched `keyboardVisible` with no keyboard on screen: the accessory bar appeared, `main` grew 84px of dead padding, and `updateAppHeight()` (which bails while the keyboard is up) stopped refreshing `--app-height`. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. ⚠️ **With the keyboard up, a shape change baselines to `window.innerHeight`** (the layout viewport, keyboard-free on both engines), never the shrunk visual height: the shrunk baseline made the settle event that follows every rotation or fold read as the keyboard closing, and the layout could not recover, since no further drop could re-arm the show branch. ⚠️ **A base gutter that a LATER `@media` block overrides needs its own fold restatement in that block, on a ZERO base**: the phone-width path picker and path preview drop to `padding: 0` under 600px, and the unconditional fold rules at the end of the file put 16px and 18px back on every phone (measured at 393 and 500). The palette's compound rule lives INSIDE the 600-768px band whose mobile.css shorthand it composes with, because outside it there is no side gutter and the addition pushed the shell 6px off centre; the response viewer's tabletop cap has a twin at the end of mobile.css, whose phone block (under 600px) otherwise outranks it. `test/foldable-layout.test.ts` simulates the cascade across BOTH stylesheets at every breakpoint, with and without the fold rules, so a moved gutter or an unscoped composition fails there rather than on hardware nobody has. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices)
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices)
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle)
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. **(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](docs/architecture-invariants.md#mobile-prompt-composer)
**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](docs/architecture-invariants.md#websocket-output-gap-reconcile)
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
@@ -352,9 +378,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Connection-loss UI** (`computeConnectionLossUi()` in constants.js, writer `_updateConnectionLossUi()` in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen **overlay** while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking **banner** once it has (the terminal scrollback stays readable). ⚠️ A **2.5s grace** is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. `navigator.onLine === false` skips the grace, since that is never a blip. Retry re-arms SSE **and** the terminal WS (`planWsReconnect` can 'give-up', and the SSE backoff caps at 30s).
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
@@ -383,11 +409,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
161 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 161 = 161, no drift either direction). ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events.
Event constants live in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**; `test/sse-registry-parity.test.ts` pins it. ⚠️ `hook:agent_working` is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events.
### API Routes
~236 handlers across 27 route files in `src/web/routes/`: system (56), sessions (37), cases (34), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), custom-model (6), reboot-restore (3), me (2), teams (2), tab-layout (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` for the current list). Beyond the `/api` routes: the `/webview/:cap/*` proxy, the `/ws/voice/stream` relay and the terminal WebSocket. Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
@@ -405,7 +431,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
@@ -436,7 +462,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.config.ts` is the everything-config behind `test:all`; `config/vitest.ci.config.ts` is the gate and derives its excludes from `config/test-suites.ts`, which is also what `vitest.browser.config.ts` and `vitest.perf.config.ts` derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them.
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `Session` is test-gated too: instead of attaching a real tmux client, it spawns a raw-mode echo PTY (`TEST_PTY_SCRIPT` in `src/session.ts`), so integration tests get a live input/output loop that echoes each byte exactly once. `test/setup.ts` gives every test file a temporary `HOME`/`USERPROFILE` (all `homedir()`-derived state, `~/.codeman` and `~/codeman-cases` included, resolves into a per-file fixture; the Playwright browser cache path is preserved), and additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions), and strips the three instance-selection vars `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (#356/#371; `test/test-env-isolation.test.ts` pins the list, and its STATIC half reads setup.ts so a dropped `delete` fails everywhere rather than only on a box that exports the var). ⚠️ `CODEMAN_DATA_DIR` is the one that matters: `getDataDir()` reads it as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run, a shell left over from `codeman web -d`) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else; `CODEMAN_INSTANCE` must be stripped in the setup file and never in a hook, because `config/instance.ts` captures it into a module-level const on first import. Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation.
**Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home)
**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
@@ -444,15 +470,15 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
**Testing against the live instance**: prod is HTTPS-only on :3000 (`curl -sk https://localhost:3000/...`). ⚠️ `w1`/`w2`/`w3` are the user's REAL sessions — never send input to them. Create your own throwaway session (`POST /api/sessions` then `POST /api/sessions/:id/shell`; creation alone leaves `pid: null` and no pane), test against that, and `DELETE` it by exact id when done.
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (138 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (device profiles in `test/mobile/devices.ts`). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
## Debugging
```bash
tmux list-sessions # List tmux sessions
curl localhost:3000/api/sessions | jq # Check sessions
curl localhost:3000/api/status | jq # Full app state
curl localhost:3000/api/subagents | jq # Background agents
tmux -L codeman list-sessions # Codeman's own socket (bare `tmux` shows the default one)
curl -sk https://localhost:3000/api/sessions | jq # Check sessions (prod is HTTPS-only; dev on :3000 is plain http)
curl -sk https://localhost:3000/api/status | jq # Full app state
curl -sk https://localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq # Persisted state
```
@@ -468,6 +494,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
## Scripts & Tunnel
**`install.sh`** (repo root, ~112KB) is the public entry point: `curl -fsSL <raw url> | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: **Tailscale** (loopback bind + guided `tailscale serve --bg <port>` HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), **LAN** (0.0.0.0 + password prompt), or **local-only**; it preserves the existing binding on re-runs via `read_existing_binding()`. Tailscale state is detected dynamically from `tailscale serve status --json` (no marker files); the installer must NEVER `tailscale serve reset` or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). `install.sh update`, `install.sh uninstall`, and `install.sh tailscale` (retrofit Tailscale access onto an existing install) also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation, `CODEMAN_TAILSCALE=1` presets the Tailscale choice (never installs Tailscale non-interactively). Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers in the file), not a hand-written list: detection, the install menu and the closing reminder all read it, which is what stops the class of bug upstream `b6d0f1fa` fixed by hand (a user with only omp installed being told no AI CLI was found). ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+11 -6
View File
@@ -19,6 +19,10 @@
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
@@ -61,12 +65,13 @@ The installer asks before every system change, and re-running the same line upda
curl -fsSL https://getcodeman.com/install | bash
```
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. It looks at what is already on the machine, asks at most three questions, then does all the work unattended and ends on the URL with a QR code for your phone. A few things worth knowing:
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
- **How it's reachable, your choice.** The installer offers three ways to reach the dashboard: **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already in use, your existing binding on a re-run), and a bare Enter never pulls in new software. A bare `codeman web` started by hand still defaults to loopback.
- **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 update` and `install.sh uninstall` also exist.
- **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.
- **Three questions, all up front.** How the dashboard is reached, optionally what to call this machine on your tailnet, and whether to run Codeman as a background service (systemd/launchd, auto-start on boot; Enter says yes). Everything that needs you, including one consent for all missing packages, one sudo password, and the Tailscale login, happens before the build, so you can walk away while it compiles.
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **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:
@@ -219,7 +224,7 @@ codeman web --https
# Open on your phone: https://<your-ip>:3000
```
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone.
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone. The installer ends on that URL with a QR code to scan, and `bash ~/.codeman/app/install.sh status` prints it again any time.
### Secure QR Code Authentication
+4
View File
@@ -23,6 +23,10 @@
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
</p>
<p align="center">
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
[
{
"id": "claude",
"label": "Claude",
"label": "Claude Code",
"shortBadge": "CC",
"enabled": true,
"order": 0,
+3
View File
@@ -30,6 +30,9 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-load-window.browser.test.ts',
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
];
/**
+14
View File
@@ -15,6 +15,12 @@ TZ=Australia/Perth
# this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=codeman
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
# are written to each image's system Git configuration when it is rebuilt, so
# deployments can configure a consistent default. Set both values together.
# GIT_USER_NAME=
# GIT_USER_EMAIL=
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
@@ -50,6 +56,14 @@ CODEMAN_USERNAME=admin
# README.md, "Reverse-proxy host allowlist".
# CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com
# The GitHub CLI (gh) and the Azure CLI (az, with the azure-devops extension)
# can be built into the images as git credential helpers, so Codeman can clone
# private GitHub and Azure DevOps repositories. Both are OFF by default and are
# NOT set here: turn them on in docker-compose.override.yml with the build args
# CODEMAN_INSTALL_GH / CODEMAN_INSTALL_AZ and, for the Docker-case agent image,
# the environment variables CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ. See
# README.md, "Private repositories".
# Optional: authenticate Gemini CLI without an interactive login.
GEMINI_API_KEY=
+109
View File
@@ -41,6 +41,115 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
### Major updates
`Start-Codeman.sh` rebuilds the image on every start, but with the layer cache,
and it refreshes the build-artefact volumes selectively: `codeman-dist` when
the checkout's HEAD moved, `codeman-node-modules` only when `package-lock.json`
changed. That is right for an ordinary `git pull`. It is not enough when a
`server.Dockerfile` change bumps the Node base image without touching the
lockfile: `node-pty` is compiled from source (there is no Linux prebuild), so
the old `codeman-node-modules` volume would keep a build made for the previous
Node version. For that case, or whenever you want to be certain of what ships,
`docker/Update-Codeman.sh` force-rebuilds the image with no layer cache, stops
the stack, removes the `codeman-node-modules` and `codeman-dist` volumes, then
hands off to `Start-Codeman.sh` for the usual start:
```sh
bash docker/Update-Codeman.sh
```
Pass `--keep-volumes` to skip clearing them (safe only if you know the
rebuilt image's `node_modules`/`dist` did not change). The scripted default
is the "Resetting the build artefacts" procedure in
[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Only those
two volumes are removed, by name within this Compose project; any volume a
`docker-compose.override.yml` adds is left alone, and application data and
case workspaces are host bind mounts, never touched either way.
## Git commit identity
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
```sh
GIT_USER_NAME='Your Name'
GIT_USER_EMAIL='you@example.com'
```
Compose passes the values to the Codeman server build, and to the server process
when it builds Docker-case agent images. Both images write the pair to Git's
system configuration during their build, so commits retain the same identity
after a container or agent image is recreated. Set both values together; an
image build with only one value fails rather than using a partial identity. An
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
the server image's system-level default.
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
server container, then recreate any Docker cases that should use it.
## Private repositories (GitHub and Azure DevOps)
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
### Turning them on
Add the build arguments to `docker-compose.override.yml` (see [Local customisation](#local-customisation)), then rebuild with `Start-Codeman.sh`. Set only the one you need:
```yaml
services:
codeman:
build:
args:
CODEMAN_INSTALL_GH: '1'
CODEMAN_INSTALL_AZ: '1'
environment:
# The same two switches for the Docker-case agent image Codeman builds.
CODEMAN_AGENT_IMAGE_INSTALL_GH: '1'
CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1'
```
The `build: args:` pair controls the Codeman server image. The `environment:` pair controls the agent image for [Docker cases](../docs/docker-cases.md), which Codeman builds on the first Docker case; an agent image that already exists is not rebuilt by this, so run `node scripts/build-agent-image.mjs --no-cache` inside the container afterwards. The same variables work in front of that command when building it by hand. Values must be `0` or `1`; anything else stops the build with an error naming the argument.
They are not `.env` settings: turning a CLI on is a per-host choice, which is what the override file is for, and a new `.env.example` key makes the in-app updater refuse to update every existing installation until its `.env` gains the key.
The Azure CLI is the large one, about 600 MB of the roughly 670 MB the pair adds. A CLI left off leaves nothing functional behind: no apt repository, no package, no `azure-devops` extension and no credential-helper entry, so git for that host behaves exactly as it does without this feature. With both off the image is functionally unchanged; it still carries the `AZURE_EXTENSION_DIR` variable, an empty extensions directory and one small layer that copies and then removes the helper script.
### Signing in
With a CLI on, the system Git configuration routes credentials through it:
| Host | Credential helper | Sign in with |
| ----------------------------------------------------- | ----------------------------------------- | ---------------------------- |
| `https://github.com`, `https://gist.github.com` | `gh auth git-credential` | `gh auth login` |
| `https://dev.azure.com`, `https://*.visualstudio.com` | `/usr/local/bin/git-credential-azure-cli` | `az login --use-device-code` |
Codeman itself still collects no Git credentials. Sign the container in once from a **Terminal / Shell** session (Run menu). The session runs as the runtime account, so the sign-in is stored under `CODEMAN_APPDATA_PATH` (`~/.config/gh`, `~/.azure`) and survives rebuilds and container recreation:
```sh
gh auth login # GitHub.com -> HTTPS -> "Login with a web browser" (device code)
az login --use-device-code # then: az devops configure --defaults organization=https://dev.azure.com/<org>
```
After that, **Add Case → Clone Repo** accepts private `https://` URLs on those hosts, and `git clone` works from any session. Until a CLI is signed in its helper prints nothing, so a private clone fails immediately with the usual authentication error rather than waiting on a prompt.
**Multi-user mode:** every Codeman user's git runs as the same server account, so these sign-ins would otherwise be shared. Clone Repo therefore runs a **non-admin**'s clone and preflight with every git credential helper cleared (`git -c credential.helper=`): a non-admin can clone public repositories and anything their own SSH setup allows, but not a private https repository through the admin's `gh`/`az` sign-in. Admins, and single-user mode, keep the helpers. A non-admin's own agent sessions still run as that same account, and with the agent-image `gh`/`az` switches on, a non-admin's Docker case with credential seeding on also receives the server account's `gh`/`az` sign-in, the same as the Claude and Codex credentials; see `docs/security-architecture.md`, multi-user mode.
Azure DevOps is authenticated with an Entra ID access token that the helper requests from `az` for each Git operation, so nothing is written to disk beyond `az`'s own sign-in. An account that has to use a personal access token can set `AZURE_DEVOPS_EXT_PAT` for the container instead (for example under `environment:` in `docker-compose.override.yml`); the helper prefers it when present. SSH remotes are unaffected by any of this and keep using the account's own keys.
Docker cases copy these sign-ins into a case container only when the matching agent-image switch is on (`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` for `~/.config/gh/hosts.yml` and `config.yml`, `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` for the sign-in files from `~/.azure`) and the case has credential seeding on. With a switch off they are never copied, even when the files exist, because a GitHub token or an Azure refresh token is usable by anything in the container. The copies are made when the container is **created**, so an existing case container never picks them up: after turning a switch on, signing in, or rebuilding the agent image, **recreate the case container** (remove it; the next session in that case creates a fresh one).
The GitHub agent skill for `gh` installs into the runtime account's home in the same session:
```sh
gh skill install cli/cli gh --scope user
gh skill update gh # after a later gh release
```
### Versions
Both CLIs, and the extension, are installed from their vendors' repositories with no version pinned, so they arrive at whatever is current when that build step runs. Docker caches the step, though: `Start-Codeman.sh` rebuilds with the cache, which keeps the versions from the first build until the Dockerfile changes at or above that step or the image is rebuilt with `--no-cache`. They are apt packages owned by root, so they cannot be upgraded from a session; `az extension update --name azure-devops` is the exception and works without a rebuild.
## Local customisation
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
#
# The scripted major-update path for the Docker Compose deployment.
#
# docker/README.md and docs/docker-self-update.md both point operators here for
# anything the in-app updater itself refuses to apply: a changed
# `server.Dockerfile`, a changed `docker-compose.yaml`, or a new required
# `.env.example` key. None of those can be applied by a container restarting
# itself — a restart reuses the existing image and configuration (see "The
# environment gate" in docs/docker-self-update.md) — so this script does the
# three things an in-place update cannot: force a real image rebuild with no
# layer cache, stop the stack, then hand off to Start-Codeman.sh for the same
# careful PUID/PGID, override-file and fingerprint handling every other start
# goes through.
#
# ⚠️ Build BEFORE stopping the stack, deliberately, same reasoning as
# Start-Codeman.sh's own build-then-down ordering: the build needs nothing
# stopped, so a slow --no-cache rebuild costs no downtime, and a build failure
# (a bad Dockerfile edit, a network blip pulling a base image) leaves the
# ALREADY-RUNNING stack untouched instead of stopped with nothing to bring it
# back.
#
# ⚠️ Clears the codeman-node-modules/codeman-dist named volumes by DEFAULT.
# Docker seeds a named volume from the image only while that volume is EMPTY,
# so a rebuilt image's fresh node_modules/dist otherwise sit unused behind a
# volume's old content and the container comes back up looking unchanged —
# exactly wrong for a script whose whole point is "be certain of what ships".
# Start-Codeman.sh clears codeman-dist when the checkout's HEAD moved and
# codeman-node-modules only when `package-lock.json` changed. A released
# server.Dockerfile change arrives through `git pull`, so HEAD moves and dist
# is refreshed, but a Dockerfile change that bumps the Node base image leaves
# the lockfile untouched while every native module (node-pty is compiled from
# source, there is no Linux prebuild) has to be rebuilt against the new Node
# ABI. Start-Codeman.sh would keep the old codeman-node-modules volume, and it
# never builds with --no-cache. This script clears BOTH volumes, and ONLY
# those two (targeted `docker volume rm` by Compose label, never
# `down --volumes`, which would also take any volume an override file adds).
# Pass --keep-volumes to opt out and reuse whatever is already in them.
#
# Usage: docker/Update-Codeman.sh [--keep-volumes]
# --keep-volumes Do not clear codeman-node-modules/codeman-dist. Safe to
# combine with a source change Start-Codeman.sh's own
# detection would have cleared anyway; unsafe if the reason
# you are here is a change to server.Dockerfile alone.
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
compose_file="$script_dir/docker-compose.yaml"
keep_volumes=0
for arg in "$@"; do
case "$arg" in
--keep-volumes)
keep_volumes=1
;;
--help | -h)
printf 'Usage: bash %s [--keep-volumes]\n' "$0"
exit 0
;;
*)
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
printf 'Usage: bash %s [--keep-volumes]\n' "$0" >&2
exit 1
;;
esac
done
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before running this script.\n' "$script_dir" >&2
exit 1
fi
# Same override-file discovery as Start-Codeman.sh, and deliberately kept in
# step with it: a stack built here and started there must resolve to the exact
# same Compose files, or this script's build could target a configuration the
# handoff's own `up` never actually uses. Compose's own precedence (measured on
# v5.5.0 with both present: it uses .yml and ignores .yaml).
override_yml="$script_dir/docker-compose.override.yml"
override_yaml="$script_dir/docker-compose.override.yaml"
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
"$override_yml" "$override_yaml" >&2
fi
compose_files=(-f "$compose_file")
for override_file in "$override_yml" "$override_yaml"; do
if [[ -f "$override_file" ]]; then
compose_files+=(-f "$override_file")
printf 'Using Compose override file: %s\n' "$override_file"
break
fi
done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
# Collision guard. Start-Codeman.sh has no equivalent; this is the only one,
# and it has to run before this script's own --no-cache build, `down` and
# volume removal below. docker-compose.yaml hard-codes `name: codeman`, so a
# second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME Compose
# project as any other checkout on the host and would operate on ITS
# containers and volumes.
#
# The project name is read from the resolved config's top-level `name` key
# (the first `name` in the output; nested ones come later), the same parse
# Start-Codeman.sh uses. `--format json` needs Compose v2.3+. This is the first
# `docker` call the script makes, so its failure is reported here rather than
# left to `set -e`, which would exit with no output at all.
if ! project_config=$("${compose_command[@]}" config --format json); then
printf 'Error: `docker compose config --format json` failed (see the message above, if any).\n' >&2
printf 'Check that Docker and Compose v2.3+ are installed and on PATH, and that\n' >&2
printf '%s and the Compose files in %s are valid.\n' "$env_file" "$script_dir" >&2
exit 1
fi
project_name=$(
printf '%s\n' "$project_config" |
sed -n 's/^[[:space:]]*"name":[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n1
)
if [[ -n "$project_name" ]]; then
# `|| true` on the pipeline's LAST command: under `set -o pipefail`, `grep -v`
# exits 1 when nothing survives the filter — the ordinary, no-collision case,
# since `docker ps` finds nothing at all on a first-ever deployment or a
# single matching (own) working_dir gets filtered out. Without it, that exit
# status propagates through the command substitution and `set -e` aborts the
# WHOLE script right here, every time, regardless of whether a collision
# actually exists — caught only by actually running this end-to-end (a
# static text/regex check on the source cannot see it). The empty-line
# filter keeps a container with no working_dir label from winning head -n1
# and hiding a real collision behind it.
other_working_dir=$(
docker ps -a --filter "label=com.docker.compose.project=$project_name" \
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null |
grep -v -F -x -- "$script_dir" | grep -v '^$' | head -n1 || true
)
if [[ -n "$other_working_dir" ]]; then
printf 'Error: Compose project "%s" is already in use by a DIFFERENT checkout:\n' "$project_name" >&2
printf ' %s\n' "$other_working_dir" >&2
printf 'This checkout is:\n' >&2
printf ' %s\n' "$script_dir" >&2
printf '\n' >&2
printf 'docker-compose.yaml hard-codes `name: %s`, so two checkouts on the same host\n' "$project_name" >&2
printf 'collide unless each one sets a distinct COMPOSE_PROJECT_NAME. Continuing would\n' >&2
printf 'rebuild and stop the OTHER checkout'"'"'s running container and, by default,\n' >&2
printf 'delete its codeman-node-modules/codeman-dist volumes.\n' >&2
printf '\n' >&2
printf 'Fix: export COMPOSE_PROJECT_NAME=<something-unique-to-this-checkout> before\n' >&2
printf 'running this script, then retry.\n' >&2
printf '\n' >&2
printf 'If instead THIS checkout was moved or renamed after its container was created,\n' >&2
printf 'the path above is its own old location: remove the old container (for example\n' >&2
printf '`docker rm -f <container>` for the codeman container) and retry, rather than\n' >&2
printf 'setting COMPOSE_PROJECT_NAME, which would start a second project beside it.\n' >&2
exit 1
fi
fi
# Same owner-detection Start-Codeman.sh uses to derive PUID/PGID for its own
# build — without it, the --no-cache build below gets Compose's untouched
# default of 1000:1000, and on any host whose appdata owner differs (99:100 on
# Unraid, per docker/README.md's chown example), Start-Codeman.sh's own
# correctly-PUID'd build during the handoff then rebuilds those layers with the
# right values anyway — so the "no cache, certain of what ships" image this
# script produces is not the one that actually ends up running.
#
# Deliberately NOT the same as Start-Codeman.sh's own handling of a MISSING
# appdata directory (which creates it): this script updates an EXISTING
# deployment, so a missing appdata path means there is nothing here yet to
# update, and creating one would just be this script quietly doing
# Start-Codeman.sh's first-run job worse.
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
printf 'Run docker/Start-Codeman.sh first to set up a new deployment.\n' >&2
exit 1
fi
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
owner_of() {
stat -c '%u:%g' -- "$1" 2>/dev/null || stat -f '%u:%g' "$1" 2>/dev/null
}
if ! owner_ids=$(owner_of "$appdata_path"); then
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
exit 1
fi
export PUID=${owner_ids%%:*}
export PGID=${owner_ids##*:}
if [[ "$PUID" == '0' ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
exit 1
fi
# --no-cache, always: a plain `build` reuses cached layers (npm install, apt
# packages, the CLI installs baked into the image) and can silently keep them
# frozen at whatever they were the day the cache was populated — exactly wrong
# for a major update, whose whole point is being certain of what actually
# ships. `scripts/build-agent-image.mjs` makes the same call for the same
# reason (see its entry in CLAUDE.md's Additional Commands table). Runs BEFORE
# the stack is stopped — see the header comment for why.
printf 'Building a fresh image (--no-cache)...\n'
"${compose_command[@]}" build --no-cache
printf 'Stopping the stack...\n'
if [[ "$keep_volumes" == '1' || -n "$project_name" ]]; then
"${compose_command[@]}" down
else
# No resolvable project name means the label filter below could match
# nothing, so fall back to Compose's own removal, and say what it really does.
printf 'Warning: could not resolve the Compose project name; clearing EVERY named volume\n' >&2
printf 'in this Compose project (override file included) with `down --volumes` instead.\n' >&2
"${compose_command[@]}" down --volumes
fi
# Targeted removal of exactly the two build-artefact volumes, scoped by label to
# THIS project (the volume key alone is shared by any other stack declaring the
# same key). Same lookup as Start-Codeman.sh's refresh. A failure is reported,
# not fatal: the stack is already down, and the handoff below is what brings
# it back up.
if [[ "$keep_volumes" != '1' && -n "$project_name" ]]; then
printf 'Clearing the codeman-node-modules/codeman-dist volumes (pass --keep-volumes to skip).\n'
for key in codeman-node-modules codeman-dist; do
volume_name=$(
docker volume ls -q \
--filter "label=com.docker.compose.volume=$key" \
--filter "label=com.docker.compose.project=$project_name" |
head -n1
) || volume_name=''
if [[ -n "$volume_name" ]] && ! docker volume rm -- "$volume_name"; then
printf 'Warning: could not remove volume %s; the container may keep serving the\n' "$volume_name" >&2
printf 'previous build from it. Remove it by hand and rerun this script.\n' >&2
fi
done
fi
# Start-Codeman.sh does everything a plain `up -d` does not: re-derives
# PUID/PGID, pre-creates CODEMAN_CASES_PATH with the right ownership, resolves
# DOCKER_SOCKET_GID, records the server.Dockerfile/docker-compose.yaml
# fingerprint the in-app updater's gate reads on every future update, and
# starts the (already freshly built) image. Reimplementing any of that here
# would only risk drifting out of step with it — hand off instead, exactly as
# docs/docker-self-update.md's own reset procedure does.
#
# ⚠️ `bash`, not a bare exec of the path: Start-Codeman.sh is committed
# non-executable (100644), the same as this script, and is documented
# everywhere as `bash docker/Start-Codeman.sh` rather than
# `./docker/Start-Codeman.sh` — execing the bare path fails with EACCES.
printf 'Handing off to Start-Codeman.sh...\n'
exec bash "$script_dir/Start-Codeman.sh"
+102
View File
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
libsecret-1-0 \
tmux \
ripgrep \
curl \
@@ -26,6 +27,88 @@ RUN apt-get update \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# GitHub CLI and Azure CLI (+ the azure-devops extension) with the same system
# git credential helpers as docker/server.Dockerfile, so an agent in a Docker
# case can clone and push to private GitHub / Azure DevOps repositories. The
# sign-ins themselves are NOT baked in: `~/.config/gh` and `~/.azure` are seeded
# per container at launch like every other CLI's credentials (CRED_STORES in
# src/docker-hosts.ts), and a helper whose CLI is not signed in prints nothing,
# so git fails fast instead of prompting. See server.Dockerfile for why the
# vendor apt repositories are configured here rather than via deb_install.sh.
#
# Each is OPT-IN and OFF by default, like the server image: CODEMAN_INSTALL_GH=1
# / CODEMAN_INSTALL_AZ=1 turn one on; off leaves no repository, package,
# extension or helper entry. scripts/build-agent-image.mjs and the in-app
# auto-build pass them from CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ in their own
# environment (for the Compose deployment: `environment:` in
# docker-compose.override.yml), and pass nothing when those are unset, so
# these defaults (off) apply.
ARG CODEMAN_INSTALL_GH=0
ARG CODEMAN_INSTALL_AZ=0
RUN set -eux; \
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
done; \
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
arch="$(dpkg --print-architecture)"; \
pkgs=""; \
install -d -m 0755 /etc/apt/keyrings; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list; \
pkgs="${pkgs} gh"; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
https://packages.microsoft.com/keys/microsoft.asc; \
chmod go+r /etc/apt/keyrings/microsoft.asc; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
> /etc/apt/sources.list.d/azure-cli.list; \
pkgs="${pkgs} azure-cli"; \
fi; \
if [ -n "${pkgs}" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends ${pkgs}; \
rm -rf /var/lib/apt/lists/*; \
fi; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then gh --version; fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then az version --output none; fi
# Outside HOME so the seeded `~/.azure` (auth files only) never has to carry
# extensions. gid 0 + group-writable, the same arbitrary-uid convention as HOME
# below, so `az extension update` works as whatever uid the container runs as.
# Created even without az; an empty directory costs nothing.
ENV AZURE_EXTENSION_DIR=/opt/az-extensions
RUN set -eux; \
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
az extension add --name azure-devops --only-show-errors; \
rm -rf /root/.azure; \
fi; \
chgrp -R 0 "${AZURE_EXTENSION_DIR}"; \
chmod -R g=u "${AZURE_EXTENSION_DIR}"
# Only an installed CLI gets a helper entry (see server.Dockerfile).
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
RUN set -eux; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
for host in https://github.com https://gist.github.com; do \
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
done; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
git config --system "credential.${host}.useHttpPath" true; \
done; \
else \
rm -f /usr/local/bin/git-credential-azure-cli; \
fi
# The npm-published agent CLIs, supplied by scripts/build-agent-image.mjs from
# config/clis.stock.json so a new stock CLI needs no edit here. The default is
# today's literal list, so a bare `docker build` still produces the same image.
@@ -44,6 +127,10 @@ RUN apt-get update \
# 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"
# 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.
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
RUN npm install -g ${CLI_NPM_PACKAGES} \
&& npm cache clean --force
@@ -167,6 +254,21 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
# Docker cases have a fresh, container-owned home directory. Declare the
# optional identity here so changing it invalidates only this final layer, then
# configure Git's system defaults. A user-level config still takes precedence.
ARG GIT_USER_EMAIL=
ARG GIT_USER_NAME=
RUN set -eux; \
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
exit 1; \
fi; \
git config --system user.name "${GIT_USER_NAME}"; \
git config --system user.email "${GIT_USER_EMAIL}"; \
fi
USER agent
WORKDIR /home/agent
+6
View File
@@ -7,6 +7,8 @@ services:
dockerfile: docker/server.Dockerfile
args:
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
GIT_USER_NAME: ${GIT_USER_NAME:-}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
image: ${CODEMAN_IMAGE}
@@ -32,6 +34,10 @@ services:
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
# Passed through only so Codeman can use the same identity when it builds
# the Docker-case agent image.
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
# Extra Host-header allowlist entries for a reverse-proxied deployment
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
# defaults to empty rather than requiring a line in every .env.
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Git credential helper for Azure DevOps, backed by the signed-in Azure CLI.
#
# Configured in the image's system gitconfig for https://dev.azure.com and
# https://*.visualstudio.com (see server.Dockerfile). On `get` it answers with
# an Entra ID access token for the Azure DevOps resource as the password, the
# same token type Git Credential Manager uses for Azure Repos. It never prompts:
# when `az` is not signed in it prints nothing, so git fails fast with its own
# authentication error instead of hanging a request that has no terminal.
#
# AZURE_DEVOPS_EXT_PAT, the azure-devops extension's own PAT variable, is used
# instead when it is set, for accounts that authenticate with a PAT.
# `store` and `erase` are no-ops: the token belongs to az, which refreshes it.
[ "$1" = "get" ] || exit 0
# Drain the request git writes on stdin; the host scoping is in gitconfig.
cat >/dev/null
if [ -n "${AZURE_DEVOPS_EXT_PAT:-}" ]; then
printf 'username=pat\npassword=%s\n' "$AZURE_DEVOPS_EXT_PAT"
exit 0
fi
command -v az >/dev/null 2>&1 || exit 0
# 499b84ac-1321-427f-aa17-267ca6975798 is the fixed application ID of Azure
# DevOps: https://learn.microsoft.com/azure/devops/integrate/get-started/authentication/service-principal-managed-identity
token="$(az account get-access-token \
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
--query accessToken --output tsv 2>/dev/null)" || exit 0
[ -n "$token" ] || exit 0
printf 'username=azure-cli\npassword=%s\n' "$token"
+137 -1
View File
@@ -39,6 +39,7 @@ RUN apt-get update \
curl \
g++ \
git \
libsecret-1-0 \
make \
openssh-client \
procps \
@@ -68,6 +69,111 @@ COPY --from=docker:29-cli \
/usr/local/libexec/docker/cli-plugins/docker-buildx \
/usr/local/libexec/docker/cli-plugins/docker-buildx
# GitHub CLI and Azure CLI (with the azure-devops extension), so a user can sign
# this container in to GitHub and Azure DevOps from a Codeman shell session and
# then clone PRIVATE repositories, both from that session and through Add Case
# -> Clone Repo. Codeman still collects no Git credentials itself: the clone
# path (src/git-clone.ts) only inherits HOME and git's config, so whatever the
# user signs in to here is what authenticates, and nothing when they have not
# (the clone then fails fast with AUTH_REQUIRED, exactly as before).
#
# Each is OPT-IN and OFF by default: the image is functionally unchanged
# unless the build gets CODEMAN_INSTALL_GH=1 and/or CODEMAN_INSTALL_AZ=1, which
# a deployment sets under `build: args:` in docker-compose.override.yml
# (docker/README.md, "Private repositories"). Off installs no apt repository,
# package, extension or credential-helper entry; all that remains is the
# AZURE_EXTENSION_DIR variable, its empty directory and one layer that copies
# and then removes the helper script. The Azure CLI is the heavy one (~600 MB,
# mostly its bundled Python). The base docker-compose.yaml
# and .env deliberately do not carry them: turning a CLI on is a per-host
# choice, which is what the override file is for, and a new .env.example key
# would make the self-updater refuse existing installs until their .env gained
# it (docs/docker-self-update.md).
#
# Both come from their vendors' own apt repositories, the same ones the
# documented one-liners configure (https://github.com/cli/cli/blob/trunk/docs/install_linux.md
# and https://learn.microsoft.com/cli/azure/install-azure-cli-linux?pivots=apt).
# Microsoft's `deb_install.sh` is deliberately not piped into the build: it does
# exactly this plus a `gnupg` install, and a remote script run at build time is
# the one step a reviewer cannot read in this file. apt reads an ASCII-armoured
# `.asc` key directly, which is what keeps `gnupg` out of the image.
#
# Not pinned, unlike the agent CLIs below: nothing in Codeman depends on a
# particular gh or az behaviour, so the pinning argument there does not apply.
# The layer cache still keeps whatever version the first build fetched until a
# --no-cache rebuild.
ARG CODEMAN_INSTALL_GH=0
ARG CODEMAN_INSTALL_AZ=0
RUN set -eux; \
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
done; \
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
arch="$(dpkg --print-architecture)"; \
pkgs=""; \
install -d -m 0755 /etc/apt/keyrings; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list; \
pkgs="${pkgs} gh"; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
https://packages.microsoft.com/keys/microsoft.asc; \
chmod go+r /etc/apt/keyrings/microsoft.asc; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
> /etc/apt/sources.list.d/azure-cli.list; \
pkgs="${pkgs} azure-cli"; \
fi; \
if [ -n "${pkgs}" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends ${pkgs}; \
rm -rf /var/lib/apt/lists/*; \
fi
# The azure-devops extension goes into a SYSTEM directory rather than the
# default ~/.azure/cliextensions: HOME is the application-data bind mount, which
# hides anything installed there at build time. The directory is handed to the
# runtime account below (next to /opt/codeman-cli) so `az extension update`
# works from a session. Nothing that runs as root executes from it. It is
# created even without az, so the chown below does not have to know.
ENV AZURE_EXTENSION_DIR=/opt/codeman-az-extensions
RUN set -eux; \
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
az extension add --name azure-devops --only-show-errors; \
rm -rf /root/.azure; \
fi
# Git credential helpers, in the SYSTEM gitconfig so they apply to every
# account and survive a fresh application-data directory. Each one answers only
# for its own host and prints nothing when its CLI is not signed in, so git
# falls through to its normal non-interactive failure. Only an installed CLI
# gets an entry: a helper naming a missing binary would print an error on every
# clone from that host.
# github.com `gh auth git-credential`, what `gh auth setup-git` configures.
# Azure DevOps an Entra ID token from `az login` (git-credential-azure-cli),
# for both dev.azure.com and the legacy *.visualstudio.com hosts.
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
RUN set -eux; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
for host in https://github.com https://gist.github.com; do \
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
done; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
git config --system "credential.${host}.useHttpPath" true; \
done; \
else \
rm -f /usr/local/bin/git-credential-azure-cli; \
fi
# Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state.
#
@@ -107,13 +213,28 @@ COPY --from=docker:29-cli \
# minimal image of this exact shape). The four CLIs live only in this prefix,
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
# system directories for the root part of the start.
# 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.
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
ENV PATH=$PATH:/opt/codeman-cli/bin
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
# already carries it for the same reason (#352). It lives in the same
# runtime-writable prefix as the CLIs, so a session can update it in place.
RUN npm install --global \
@anthropic-ai/claude-code@2.1.258 \
@google/gemini-cli@0.58.0 \
@openai/codex@0.152.1 \
opencode-ai@1.18.26 \
pnpm@12.6.0 \
&& npm cache clean --force
# Keep the web server and every local Codeman session unprivileged. PUID and
@@ -153,7 +274,7 @@ RUN set -eux; \
--shell /bin/bash \
"${CODEMAN_RUNTIME_USER}"; \
fi; \
chown -R "${PUID}:${PGID}" /opt/codeman-cli
chown -R "${PUID}:${PGID}" /opt/codeman-cli /opt/codeman-az-extensions
WORKDIR /opt/codeman
@@ -181,6 +302,21 @@ EXPOSE 3000
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh
# Declare the optional identity immediately before configuring it so a change
# invalidates only this final layer. This is declarative setup: a persisted
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
ARG GIT_USER_EMAIL=
ARG GIT_USER_NAME=
RUN set -eux; \
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
exit 1; \
fi; \
git config --system user.name "${GIT_USER_NAME}"; \
git config --system user.email "${GIT_USER_EMAIL}"; \
fi
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/index.js", "web"]
+10
View File
@@ -757,3 +757,13 @@ works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
empty Codeman name, so the peer name stays derived: agents should name their
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`,
`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto
names leave Claude to title the conversation. 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`.
+22
View File
@@ -311,6 +311,15 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
turn that never started. Verified live; this is the most common silent failure on
this endpoint.
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
a separate key, and the server re-presses Enter while the prompt is still visibly
sitting on the composer. Written straight into the pane in one piece, a prompt of
about a hundred characters or more is taken as a paste by Claude Code, its `\r`
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
the raw write, and an explicit `"useMux": false` forces it.
```bash
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' \
@@ -691,6 +700,19 @@ normal `caseName`/`mode`/etc. body)
jarring than a full relaunch, and folding it into the one-shot path is
separate work — see `docs/custom-model-endpoints-plan.md`).
## 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.
| Method | Path | Body | Notes |
| -------- | ----------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. |
| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. |
| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. |
| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. |
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
+587 -13
View File
@@ -10,12 +10,27 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### Default bind, and the non-loopback warning path
**Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Installer note** (1.8.x, `install.sh`): interactive installs now PROMPT for the binding, defaulting to LAN (`0.0.0.0`) with a required password prompt (skipping the password needs an explicit confirm and prints a loud warning); non-interactive installs keep loopback unless `CODEMAN_HOST` is preset, and re-runs/updates preserve the EXISTING binding (`read_existing_binding()` parses the current systemd unit / launchd plist). The server binary's own default is unchanged. **Full model: `docs/security-architecture.md`.**
**Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Installer note** (`install.sh`, installer v2 since 2026-09-20): interactive installs PROMPT for the binding up front, defaulting to Tailscale when the node is already connected, else LAN (`0.0.0.0`), and to the existing choice on a re-run; a LAN bind asks for a password, and skipping it is a confirm that DEFAULTS TO YES (owner decision 2026-09-20) and ends on a loud red notice; non-interactive installs keep loopback unless `CODEMAN_HOST` is preset, and re-runs/updates preserve the EXISTING binding AND password (`read_existing_binding()` parses the current systemd unit / launchd plist, and the `--lan`/`--tailscale`/env preset paths read `${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}`, since a flag re-run used to rewrite a password-protected unit open). The server binary's own default is unchanged. **Full model: `docs/security-architecture.md`.**
### Instance isolation and the multi-instance attach danger
**Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`, and any new `tmux -L` caller through `resolveTmuxSocketName()` (same module): it applies the `CODEMAN_TMUX_SOCKET` override only when the name is safe and falls back to `DEFAULT_TMUX_SOCKET` otherwise. `TmuxManager` was the only such caller until `codeman tui` shelled out to tmux from a SECOND process for its degraded-mode listing and its attach handoff; a hardcoded `codeman` there would have pointed a beta instance straight at prod's panes.
### Reverse-proxy base path
**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress:
- **(server ingress)** `stripBasePath()` runs inside Fastify's `rewriteUrl` so ALL routes stay declared prefix-agnostic (`/api/...`, `/ws/...`) — and a request arriving WITHOUT the prefix (hooks, health checks, docker bridge, all hitting the raw port) is left untouched, so the server answers at both.
- **(server egress)** one `onSend` hook prepends the base to every root-absolute `Location` header, covering all redirects.
- **(HTML)** `renderIndexHtml` rewrites the shipped `<base href="/">` to the mount and injects `window.__CODEMAN_BASE__` — the template's asset refs are all RELATIVE so `<base>` handles them for free.
- **(frontend runtime URLs)** root-absolute URLs ignore `<base>`, so `CodemanBase.url()` (constants.js) is the route builder, applied transparently by a `fetch` wrapper and explicitly at the few EventSource/WebSocket/`window.open`/`<img|iframe|a>`-src sites.
- **(sw.js/manifest)** the worker derives its base from `self.location`, the manifest uses relative `start_url`/`scope`.
- **(web-tab proxy)** `proxyPrefixFor(cap, basePath)` is the single base-aware root that cascades to the injected `<base>`, root-absolute HTML rewrites, the `runtimeUrlShim`, `Set-Cookie` Path and `Location` rebasing — while the INGRESS parsers (`capabilityFromProxyPath`, `resolveUpstreamUrl`) stay base-agnostic because `rewriteUrl` strips the prefix before routing, and `capabilityFromReferer(referer, basePath)` strips it from the browser-supplied Referer.
⚠️ `--base-url` rides the daemon relaunch via `buildWebArgs` and the service unit via `resolveServicePlan`.
Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.ts`; HTML injection in `test/render-index-html.test.ts`.
## Session launch modes
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
@@ -48,22 +63,58 @@ Model is NOT a session field: it is a composition entry in the profile's config
**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incLine truncated
Further detail: with the registry, a CLI that declares its own composer glyph and working line in `capabilities.workDetect` gets the same screen-probed idle confirmation claude gets (see the ❯ note in CLAUDE.md's idle-detection paragraph), and one that declares neither keeps the output-stabilization behaviour. The tmux-only rule now covers all EIGHT external modes (OMP included): none has a direct PTY fallback, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line.
Grok's permission clamp: the Run button sends `grokConfig.alwaysApprove: true` (like antigravity's bypass), and the clamp's only-if-sent branch strips it for non-granted owners in multi-user mode. Grok lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough, which is UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch. Grok's user guide is `docs/grok-integration.md`; the resolver's `grok --version` probe is surfaced (path + version) by `GET /api/grok/status`.
DeepSeek: the Run button gates on `isDeepSeekRunnable()` and a `web`/`headless` profile is refused at spawn because it cannot drive a pane. The status bridge gives dsh real `stop`/`blocked` signals, real Approvals Inbox items, and the `agent_working` event that clears an alert answered in the terminal. `clampEnvOverridesForOwner()` drops (rather than rewrites) the three keys, and dropping falls through to what `_configureCliEnv()` exports, which is the clamped value; every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them.
OMP's registry entry declares `privilegedParams: []` and a launch spec that only ever passes `--model`/`--resume`/`--continue`.
### Remote sessions over SSH
**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON); the pure pieces (backoff schedule, per-session reconnect state, `decideReconnect` eligibility) live in `src/remote-reconnect.ts` (tests: `test/remote-auto-reconnect.test.ts`), while `tmux-manager.ts` owns the live pane probe + timers. Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`.
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links.
### Remote SSH cases
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input.
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids).
Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`.
### Docker cases
**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-<name>` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-<slug>`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-<id8>`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume <rid> || --session-id <sid>` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume <id>` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,setLine truncated
**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-<name>` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-<slug>`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-<id8>`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume <rid> || --session-id <sid>` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume <id>` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,setLine truncated
Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`.
Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different in-container directories (`classifyAdoptContainerConflict` in `docker-hosts.ts`: an exact twin on the same container AND directory is refused, an owned container still backs exactly one case, and a container another user adopted is refused), which is what the Add Case panel's "copy an existing case" picker relies on. The wire carries `CaseInfo.docker.owned` ONLY when false, so the picker tests `=== false`, never truthiness. The export-path closures (refusing `docker commit`, skipping `docker pause`) are two lifecycle touches the original design missed and that are easy to re-introduce.
### CLI registry
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. **No code outside `stock.ts` may branch on a CLI id**; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (`profiles.ts`), and `test/cli-registry-no-id-branching.test.ts` fails the build if an id check reappears — it matches `===`, `!==`, `case '<id>':` and `[...].includes(mode)`, because an earlier `===`-only version let 36 negated branches survive the conversion (including a seven-mode Ralph chain whose own comment asked the next person to keep it in step with `isExternalCliMode()` by hand). A second CI-gated guard, `test/frontend-cli-no-id-branching.test.ts`, covers the two frontend files the run-menu consolidation (#458) touched, `session-ui.js` and `mobile-overview.js`: its allowlist is keyed by expression rather than line number, with an occurrence count per entry, so a new branch reusing an already-approved expression fails as a count mismatch instead of riding in on the old approval.
⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation.
⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug.
⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression.
⚠️ Five fields are DECLARED-FOR-LATER and read by nothing (`accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured (except `accent`, measured against styles.css on 2026-09-21), so re-measure before wiring one up; the list is pinned so it cannot quietly grow. (`shortBadge` left the list when the Settings CLI management list started showing it.)
Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden.
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()`: mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`.
## Session data and lifecycle
### Input delivery and WS resilience
@@ -90,6 +141,14 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
**Signal availability is decided by MODE, not by `isExternalCliMode()`.** `stop` and `blocked` come from Claude Code hooks, so `hooksAvailableForMode()` is true only for `claude`: `shell` is not an external-CLI mode but installs no hooks either, so `until=stop` on a shell session is a guaranteed unresolvable wait dressed up as a timeout. The behavior split is deliberate and must survive refactors: an EXPLICIT request for an unavailable signal is a 400 naming the mode, while the DEFAULT set (`stop,idle,exit`) silently drops them and echoes the narrowed set back as `wait.until`, because omitting the parameter must never 400. Signal quality is not uniform either: `stop` is definitive (Claude Code says the turn is over), `idle` is inferred from output stabilization plus prompt detection and can flap mid-turn when a spinner pauses, which is why `stop` is the documented default to orchestrate on and `idle` is the fallback for sessions that emit no hooks. ⚠️ **`idle` being ACCEPTED for a mode does not mean it ever FIRES there.** `startShell()` emits exactly one `idle` on a 500ms readiness timer and nothing afterwards, so a shell session sits at `status:'idle'` no matter what its pane is doing; since send-and-wait and `fresh=1` both require a TRANSITION, both can only time out on a shell worker (measured: a default `wait` on `sleep 4` burned its full 25s). The ❯-prompt and spinner detection that drives the real `working`/`idle` cycle is Claude's output format, so hook-less modes synchronize with `wait-output` markers or `exit`, and the docs must say so rather than listing `idle` as "available" and letting the reader infer it is usable. ⚠️ **Signals are edge-triggered with no history**, and that is a real orchestration limit: a signal that fires while no waiter is registered is gone, unobservable by any later wait variant (`until=stop` after the turn ended just times out, `fresh` or not — measured, R2-A). Documented client patterns must therefore register the waiter before the event can fire (send-and-wait) or gather on latched `wait-output` markers with `from=buffer`; the skill's fan-out flow was rewritten accordingly, and "fire-and-forget N prompts, then gather signal-waits sequentially" must never be documented again. The durable fix, a server-side latched last-signal-per-turn, is deferred with Part 3 of `docs/agent-control-plan.md`. ⚠️ Relatedly, the route corrects liveness that `SessionStatus` cannot express: `currentSignalFor()` answers `exit` whenever `pid === null` (exited, detached, or created-and-never-started) **or the mux pane is dead**, because `Session` parks a DEAD PTY at `_status = 'idle'` and trusting the status would answer the default wait `{signal:'idle', immediate:true}` for a crashed worker while `until=exit` blocked forever on an event that already happened. ⚠️ **`pid` alone cannot carry liveness for a tmux-backed session**: that pid is the local `tmux attach` CLIENT, so a worker exiting inside its pane leaves `pane_dead=1` with the client alive and `pid` never goes null — the `pid === null` branch is unreachable in the normal configuration (unit tests exercise it because `MockSession` sets `pid` by hand; only a live instance showed the gap). Liveness is therefore probed at the mux layer: `workerIsDead()` consults `mux.isPaneDead(muxName)` with a ~750 ms per-pane cache, ONLY on blocking waits (measured: 0 tmux execs across 100 non-wait input POSTs — the browser hot path pays nothing), plus a refcounted 3 s `watchForDeadWorker` interval so a worker dying while a wait is parked resolves it in ~3 s instead of burning the timeout. The probe fails SAFE by construction ("cannot tell" is never "dead": non-mux sessions, a missing or throwing `isPaneDead`, all return false). On send-and-wait, a "successful" `send-keys` into a dead pane additionally overrides `delivered` to `false` and rolls the dedup seq back (`undoOnFailure`), because the bytes went nowhere and a retry against a restarted worker must not be refused as a duplicate. The cost is that a just-created session reads as `exit`, which the wire docs must spell out as "not started yet"; the fix lives at the route rather than in `signalForStatus()` because the registry deliberately holds no `Session` reference. ⚠️ Two `claude`-mode cases still lose hooks for reasons outside the registry: a Docker case cannot reach a loopback-bound Codeman without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, and a remote-SSH case runs the agent on another host whose hooks may never reach this server. Bounds are env-overridable (`CODEMAN_WAIT_MAX_MS`, `CODEMAN_WAIT_DEFAULT_MS`, `CODEMAN_WAIT_MAX_PER_SESSION`, `CODEMAN_WAIT_MAX_PER_OWNER`, `CODEMAN_WAIT_MAX_TOTAL`, `CODEMAN_WAIT_BUFFER_SCAN_BYTES`) and each is clamped to a hard bound so a typo degrades to the default instead of disabling the protection; they are internal tuning knobs like the rest of `src/config/`, NOT part of the SemVer-covered env-var surface in `versioning-policy.md`. Tests: `tesLine truncated
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant.
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route.
Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused).
Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live).
### Auto-resume on usage limit
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
@@ -98,6 +157,8 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`.
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`.
### Cron jobs
**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini/antigravity/pi agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
@@ -106,6 +167,8 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). ⚠️ **Transcript history is three stores** (#386): Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Rows are keyed by whatever id that CLI names the conversation with and folded into their owning session via the `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice; the field keeps its Claude-era name and is not Claude-only). A codex row additionally carries `resumeId`, the rollout's own thread id, set by the scanner and never by a live session, which is what lets a row be resumed through `codexConfig.resumeSessionId` while a row without one stays a fresh session; the alias chain therefore includes `config.codexConfig?.resumeSessionId`, and a fresh codex pane is matched by `session_meta.originator` (`codeman_<sessionId>` for every pane Codeman spawns); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): **pinning** via `POST /api/sessions/:id/pin` (`session:pinned` SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); **cross-device tab order** via `PUT /api/session-order` (`session:orderChanged` SSE, persisted in `state.json`; pure `normalizeSessionOrder`/`mergeSessionOrder` in `src/session-order.ts`: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); `firstPrompt` is backfilled for sessions whose id != transcript UUID and the most recent prompt (`lastPrompt`) is shown + searched (COD-140/145).
Further detail: `resumeHistorySession()` is what sends `codexConfig.resumeSessionId` for a row carrying `resumeId` (a conversation already on disk), and a row without one is a genuinely fresh session. Every surface that re-projects these rows has to carry `resumeId` through, the phone overview included, or a tap on that surface silently starts a second conversation.
### Session lineage lines (tab → tab it spawned)
**The relationship did not exist before this** (1.17.0): `SessionState` had no `parentSessionId`, `quick-start` recorded only the multi-user *human* owner, and an agent's spawn call is plain `curl` from a tmux pane, so nothing in the request identifies the caller (`SO_PEERCRED` needs a unix socket; the API is TCP). The caller therefore supplies it — every managed pane already gets `CODEMAN_SESSION_ID` from `session-cli-builder.ts`. Two equivalent inputs, body wins: a `parentSessionId` field on `POST /api/sessions` / `POST /api/quick-start`, or the `X-Codeman-Parent-Session` header, which exists so the agent skill can set it ONCE on its shared curl invocation and have every present and future spawn recipe carry it.
@@ -118,6 +181,10 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths).
Further detail (current geometry and colors, superseding the dip numbers above where they differ): ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels.
⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in.
### Auto-named sessions (first prompt → tab title)
**Shipped opt-in, in the prefix form, after a review round that found five ways the first cut named a tab wrong** (#376, 1.30.0). The contributed version renamed on EVERY prompt (`applyAutoName` never left the eligible state, so "fix the login bug" then "1" left the tab named **1**), fed its tracker from every write path (shell tabs renamed after each command, every Ralph and respawn tab named "Read @ralph_prompt.md and follow the instructions."), stayed in escape mode after a bare Esc until a byte in `0x40-0x7e` arrived (Esc then "fix the login bug" submitted **ix the login bug**, Esc then a CJK prompt submitted nothing and the following prompt lost its first character, Esc then digits grew the escape buffer to 19001 characters), treated the newlines inside a bracketed paste as Enter, cleared the draft on ANY CSI (including the SGR wheel reports Codeman forwards to claude ≥ 2.1.187, so "fix the " + wheel + "login bug" gave **login bug**) and on Tab (the `@` completer), and replaced the whole name, which dropped the case from the tab and reset `_nextCaseSessionStartNumber()` so every new session in the case became `w1-<case>` again. Each of those is a named rule in `session-auto-name.ts` with a test.
@@ -132,23 +199,29 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
**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.
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`.
### Full-scrollback replay
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's Line truncated
**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell FULL history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. That gesture instead pulls a BOUNDED window of it (`?full=1&tail=<TERMINAL_TAIL_SIZE>`, so a capture beyond 1 MiB comes back `truncationReason: 'tail'` and the banner offers the rest), and skips the replay when the window holds no more rows than the browser already has. ⚠️ **That skip runs BEFORE the downgrade guard, and it never touches the banner state**: the guard reads "smaller than the browser" as "tmux has nothing more to give", true of an unbounded capture and false of a window cut at the tail size, so a bounded window that reached it marked the session `exhausted` and removed **Load full history** while tmux still held the rest; and re-labelling the banner from a skipped payload would call a terminal that holds all of a Load full history pull "the most recent 1 MiB". A skipped window that came back truncated puts the session on the 60 s cooldown (`_fullHistoryRepullUseless`), because it can never reach anything older than the browser shows and `tail` is applied AFTER the server's synchronous `capture-pane` of the whole history, so each ask costs every client an event-loop stall (about 0.3 s at 100k lines); an untruncated one keeps the 4 s cooldown, since it is all of tmux's history and the next burst can add to it. Declining the gesture outright was a dead end: after any burst a shell pane held about one screen of browser scrollback, and the button renders only once a replay was truncated, so a young shell tab had no way back to history tmux still held. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, and the marker callback supplies accurate parse timing. ⚠️ **How the load ENDS depends on where the payload came from**, and `_bufferLoadFinishOpts` (app.js) is the one place that decides it for all four fetch-and-write paths. A payload built from the server's accumulated byte history is current up to the response, so the events queued during the load already appear in it and stay DISCARDED; replaying them would duplicate output, most visibly Ink's cursor-up redraws. A pane capture (`mux-visible` or `mux-full-history`) is current only up to CAPTURE time, so `_finishBufferLoad` replays the queue from the response's own arrival timestamp (`since`) and the pre-capture events stay dropped. ⚠️ **A path that then restores a scroll position must re-take the sticky-scroll baseline** (`_syncStickyScrollBaseline`): the replay runs inside `chunkedTerminalWrite` before its promise resolves, with the terminal freshly reset, so `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` as true and the next `flushPendingWrites` would scroll to the bottom over the restore. ⚠️ The cutoff is a client-side timestamp and the server broadcasts on a batch timer (8ms WebSocket, 16-50ms SSE), so a batch pending when the capture ran arrives after the response and replays although the capture holds it — bounded by one batch interval, and closable only server side by flushing that batch before the capture. Tests for the three: `test/terminal-flush-budget.test.ts` pins which sources flush, `test/terminal-buffer-flush.test.ts` pins the `since` cutoff and the baseline re-take, and `test/capture-load-window.browser.test.ts` drives both against a live server. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the Line truncated
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, which is the signature of a clamp rather than a race (`getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does not, so a terminal under 40 columns or 10 rows reports a pane permanently bigger than itself and would replay on every tab switch); and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
**A capture reports the geometry it was taken at** (#435): a visible frame repaints each row at an absolute position, counting up to the pane's height and out to the pane's width, so a terminal smaller than that pane damages it two ways at once. Too short and every address past the browser's own height clamps onto the last line, overwriting the rows underneath (measured: against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew the survivors twice). Too narrow and each row is painted out to the pane's width, so the browser wraps every painted row and the wrap on the last one scrolls the whole frame up by one. Nothing in the response used to say what geometry the frame was built for, so the client could not see either case. `PaneCaptureOptions.capturedGeometry` carries it out, and the terminal response publishes it as `captureCols`/`captureRows`. ⚠️ **Both fields are ABSENT unless a frame was really positioned**, and every consumer must test `Number.isFinite` rather than truthiness: `mux-visible` is necessary but not sufficient, because when the `display-message` cursor query fails `capturePaneBuffer` skips the snapshot repaint and returns the raw capture, and the route still labels that non-empty body `mux-visible`. A body that positioned nothing has no geometry to describe and nothing to repair, so a comparison that fires there buys a second capture, a reset plus chunked rewrite, a dropped and reopened WebSocket and a discarded xterm snapshot for no gain. ⚠️ **The comparison runs on `mux-visible` ONLY.** A full-history body is linear scrollback closed by a RELATIVE cursor move, which is relative precisely so the browser's row count need not match the pane's, and a byte-history body carries no row alignment at all, so a size mismatch damages neither and a replay repairs neither. That gate matters because the first select of every non-shell session per page takes the full-history path, where an ungated comparison would fire most often on the one response it cannot help, at the price of a second whole-scrollback capture. ⚠️ **The replay is capped at one attempt and latches per session when it cannot converge.** `resizeRetry` stops two competing fits trading replays forever; a pane already drawing at the size just requested is left alone, because a retry would capture the identical frame; and a pass that still does not converge joins `_geometryRetryUseless`, so the case `Session.resize` declines outright (a small viewport while a desktop viewport's size claim is live, where the retry re-sends the same declined resize and captures the same pane) costs one attempt per session per page load instead of one per select. ⚠️ **The clamp used to manufacture that equality, and no longer can** (#464). This paragraph previously explained it as the signature of a clamp: `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did not, so a terminal under 40 columns or 10 rows reported a pane permanently bigger than itself and would replay on every tab switch. That divergence is fixed at the source — `syncTerminalGeometry()` (terminal-ui.js) fits, floors and APPLIES in one step, so the browser terminal IS the size it reports. The only remaining reason the two can differ is a resize the server declined, which the server now reports back (`Session.ptyGeometry`, the `{"t":"zc"}` frame and the resize response) for the client to adopt by COLUMNS. The equality guard stays, for the plain case of a pane already at the requested size. ⚠️ **A retry pass must not re-arm `_fullHistoryLoaded`**: it did not consume the full-history pull, and re-arming it would spend a whole-scrollback capture on the next select. That branch is currently unreachable by construction, since reaching it needs `source === 'mux-visible'` while a `full=1` pass is answered `mux-full-history` or `history`; a static test over the source is the habit this repo uses for an invariant nothing can execute. Tests: `test/capture-geometry-retry.browser.test.ts` (eight cases, five of which fail against the merge base), `test/tmux-capture-full-history.test.ts`, `test/routes/session-routes.test.ts`.
⚠️ **A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery must verify itself** (`_scheduleDroppedOutputRecovery`, app.js): a hole in a TUI byte stream is a desynced cursor, which is muffled text (#464). It was a fire-and-forget 2s timer that nulled its own handle and then called `_onSessionNeedsRefresh()` — whose early returns (a buffer load in flight, a refresh already owning the session) are MOST likely to be true during exactly the burst that caused the drop, so the recovery was lost silently and the bytes were never replayed. `_onSessionNeedsRefresh` now returns whether it actually repainted, and the scheduler re-arms while it has not, bounded by `DROP_RECOVERY_MAX_ATTEMPTS` because the early returns it retries past are transient contention. A refresh that died at the capture fetch DEADLINE (it returns `'deadline'`) is a stalled link, not contention, and is NOT retried: each retry would be another `?full=1` capture waiting out a deadline of up to two minutes. The same debounce still collapses a burst into one attempt, and the `TERMINAL DROP` crash-trail line sits behind it, since one line per dropped frame evicted the whole 50-entry trail in under a second. Tests: `test/dropped-output-recovery.test.ts`, whose retry case and no-retry case only pin the fix as a pair.
### Terminal scrollback: strip flavors and wheel/touch forwarding
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) 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). ⚠️ 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 narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
⚠️ **What the full strip 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 (so clicks keep being reported exactly as before), 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.
⚠️ **What the full strip 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 forwards the wheel; every other mode 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. ⚠️ "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()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): 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 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()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): 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 — 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`.
**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`.
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`.
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
@@ -158,10 +231,113 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
Further detail, closing: ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`.
### Circuit breakers: Ralph and PTY-exit
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. **Distinct: PTY-exit breaker** (COD-115/118/#147, `session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits (crash loops on attach), blocks further auto-restarts, broadcasts SSE `session:respawnBreakerTripped` + push (in `PUSH_EVENT_MAP`). Reset ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive` (sent by the user-facing restart control) — the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. Sessions also scrub inherited `TMUX`/`TMUX_PANE` env so Codeman-in-tmux doesn't nest. Tests: `test/respawn-pty-breaker.test.ts`.
### An exited agent in a live pane (`paneExit`)
**Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-<fragment>` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manLine truncated
**A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close:
- ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep.
- **At least `CLEAN_EXIT_CONFIRMING_READS` (2) authoritative reads must agree.** `TmuxManager.getPaneExitReadCount()` counts them. A repeat of the same pane pid, status and signal adds one, anything else starts again at 1, and a failed, empty or skipped read never reaches `applyPaneExits()`, so it neither confirms nor resets. A mux without the method never has a session closed.
- **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay.
- **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand.
Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.claude-images` while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`.
### Dead-pane respawn: the resume pin
**The dead-pane respawn needs the same resume pin as a custom-model `restartCli()` and shares it** (`_buildRespawnPaneOptionsWithResumePin()` in `session.ts`, used by `restartCli()`, the dead-pane respawn in `_setupOrAttachMuxSession()`, and its create path when that path RELAUNCHES a CLI: after a failed respawn, or when tmux lost the whole session rather than the pane): a pane whose agent EXITED owns a transcript too, so recovering one with the bare launch line hit the same refusal and the conversation was stranded behind a tab that looked merely idle. The pin walks three candidates in order — the conversation chain's tail, the launch seed, then the session's own id — and takes the first one a transcript backs, never `_claudeSessionId` (which also holds history-correlated GUESSES keyed on the working directory, and launching from one would open and write to a conversation that was never this pane's). ⚠️ The create-path pin is written to `_resumeSessionId` as well, so unlike `restartCli()`'s one-respawn pin it PERSISTS through `toState()` as `resumeSessionId`: that field means "what the user asked to resume at creation, or what recovery pinned", and after a dead-pane respawn `_claudeSessionId` names whatever the walk actually pinned rather than the chain tail. ⚠️ A candidate no transcript backs is passed over, and falling off the end of the walk ADDS no pin (the options keep whatever launch seed they already carried): a divergent pin leaves `--session-id <this.id>` in the fallback branch, where a failed resume collides all over again, while pinning an id with no transcript prints claude's "No conversation found" into a brand-new session's scrollback and costs the running branch its `nice` priority (`wrapWithNice()` prefixes only the first branch of an `a || b`). ⚠️ Remote and docker sessions are never pinned: their pane commands are already self-healing, the conversation lives on the far side, and a local id resolves to nothing there.
### Idle detection: composer glyph and working line
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds.
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`, 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.
⚠️ A third, optional `workDetect` field, `watchingLine` (plus `watchingLines`), tells a quiet pane that is still RUNNING something apart from one that wants a human; it is read from the same idle-confirmation capture. See [The watching signal](#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you).
⚠️ A fourth, optional `workDetect` field, `awaitingLine`, makes a turn that ENDED waiting for workers the CLI started count as WORKING: with background agents or an ultracode workflow still running at turn end, Claude closes the turn with `✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when they report back, so the pane is quiet with the composer up and every other signal read it as idle (reported 2026-09-28 on a live 2.1.283 session). `_probePaneWorking()` ORs it into the same capture. ⚠️ It must never be searched across the pane like `workingLine`: Claude renders the row from a `useState` snapshot taken at turn end and never redraws it, so the words stay on screen after the workers finish and a pane-wide match would pin the tab busy until they scroll off. `isAwaitingWorkers()` (pure, `session-activity.ts`) finds the composer (the LAST row carrying `promptGlyph`, bare or boxed), walks up at most `AWAITING_SEARCH_ROWS` past blank, box-drawn and indented rows, and tests only the first column-0 row. ⚠️ Keep the pattern anchored on column 0 (`^✻`): Claude's own rows start there and the agent's prose never does, which is what stops an agent from holding its own session busy. It is deliberately NOT tested on the PTY stream, where redraws of the stale row would re-mark working. Tests: `test/session-awaiting-workers.test.ts`.
### The watching signal (a quiet pane that is not waiting for you)
**A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ A capture that FAILS clears the label (and announces the change) rather than keeping the last one: a stale label opens the next idle prompt already acknowledged, so keeping it would turn a failed `capture-pane` into a missed alert, while clearing it costs at most an alert the next readable capture takes back. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen.
**The fix is the alert that does not fire; the badge is cosmetic.** `hook-event-routes` passes the label to `notePrompt()`, which opens the idle item ALREADY acknowledged (`acknowledgedAt` + `acknowledgedReason`). Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", so the item stays pending, answerable and available as Read My Mind context, and a wrong label costs a card that does not blink rather than an alert that was never created. Every surface follows from that one flag — the broadcast carries `acknowledgedReason` so a live page declines to arm (`_onHookIdlePrompt`, settings-ui.js), the push is skipped, a reloading page reads `acknowledgedAt` in `seedApprovals()` as it always did, `classifySession()` and `pendingApprovalCount()` (tui-model.ts, tui-render.ts) ignore an acknowledged item, and the TUI card drops to the `info` tone and says why. It re-arms for free: the next idle prompt supersedes the item and is built fresh. ⚠️ Only `idle` is eligible, so a permission or question dialog still goes red whatever else the agent started — but a prose question is NOT a dialog, so an agent that arms a monitor and then asks "which branch?" in plain text is silenced along with the false alarms. That is the accepted cost of the design and the reason the kind gate sits at the single place items are created.
⚠️ **A chip that waits on a HUMAN must never count as watching**: Claude's Artifact comment monitor (an agent that published a page and hears nothing until somebody comments) is refused for the whole row by a `^`-anchored negative lookahead keyed on "Artifact" alone, so a footer cut off mid-chip is refused too, and the `^` is what stops the engine retrying past the chip and reporting a shell beside it.
**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both — its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'` — no hook event from a codex session reaches `notePrompt()`, so a forged label costs a wrong badge and nothing else, and a CLI that gains hook signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces).
### Workspace-trust dialog auto-accept
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck.
⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw.
⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one.
⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes).
⚠️ 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).
### Owner tab layouts
**Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs.
⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job.
⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent.
⚠️ The layout does not replace `PUT /api/session-order`, it PROJECTS onto it: `tab-layout-legacy-order.ts` is the pure translation both ways (`putLegacyOrder()` recomposes a global order from the owner's groups), so changing one side without the other silently desyncs the tab strip from the stored layout.
⚠️ **Reconciliation is gated on a SUCCESSFUL restore** (`markRestorationComplete` / `markRestorationFailed` / `markRestorationSkipped`, plus `assertDeletionReady()`): pruning refs against a session list that failed to load would delete live tabs, so a failed restore must leave the layout untouched.
`PUT` takes exactly `{baseVersion, layout}` (any other key shape is a validation error), answers a stale `baseVersion` with the current layout rather than clobbering, and is capped at 128 KiB. Broadcasts `tab:layoutChanged`, owner-routed via `deriveTabLayoutSseHint`.
### Hook events and workspace hook installation
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted` (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints.
The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path.
⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog.
⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
### Reboot restore
**Reboot restore** (#411/#442, `src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): a host reboot takes the tmux server with it, so every pane dies and the board comes up empty with no explanation. At boot Codeman works out which sessions that reboot destroyed, holds the plan IN MEMORY (no new state file, and a server restart simply drops the offer), and the banner asks.
⚠️ **The heuristic decides whether to ASK, never whether to act**: two signals have to agree (the socket holds no panes at all while state still lists sessions, AND the host booted after the newest persisted activity), and a wrong yes costs one dismissable line rather than N CLI processes nobody asked for.
⚠️ Rebuilding is TAKE-then-build: entries leave the plan synchronously before the first `await` and the route is single-flighted per owner, so a double-click or a second device cannot put two panes on one conversation. Anything that never became a pane goes BACK on offer, with one deliberate exception, `already-live`, which unlike a missing workspace or a withdrawn grant cannot stop being true.
⚠️ Three things are re-checked at click time rather than trusted from boot (the owner's privilege grant, the workspace still being on disk, and the conversation not already being live), and the already-live sets are read FRESH per iteration rather than snapshotted: the loop awaits a real `startInteractive()` per entry, so a snapshot taken before it is tens of seconds stale by the tenth entry and would miss a conversation the user resumed by hand in that window. The confinement re-check is keyed on the entry's OWNER, never the caller, or an admin spending another user's entry is waved through by `isWorkingDirAllowed`.
⚠️ A rebuilt session comes back **attached, idle and disarmed**: the pane is NEW, so terminal scrollback is gone (the banner says so) while the conversation continues, respawn controllers and Ralph loops are never re-armed, and `reapplyPersistedSessionState(..., { rearmAutoResumeSchedule: false })` keeps auto-resume ENABLED but drops the pre-reboot `autoResumeAt` stamp, or one click has every restored session type `continue` into itself a minute later, unattended. That option exists only for this path; a Codeman restart still re-arms, because the limit footer will not reprint on its own.
⚠️ The rebuild passes `nameSource` through, or the constructor re-infers it from the name and a hand-renamed session shaped like `w<n>-<case>` comes back as `placeholder` for auto-naming to overwrite.
⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (`remote-or-docker`), because both need another host or container to be up.
⚠️ A failed rebuild is undone with `discardPartiallyBuiltSession()`, deliberately NOT `cleanupSession()`: the delete path would count the session's tokens into the lifetime totals, demote a pinned record to `stopped` (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's `.claude-images`.
⚠️ `os.uptime()` reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: `test/reboot-restore.test.ts`, `test/routes/reboot-restore-routes.test.ts`, `test/routes/reboot-restore-rebuild-failure.test.ts`, `test/discard-partially-built-session.test.ts`.
## Features
### Attachments
@@ -212,12 +388,25 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
### File Viewer text view: rendered markdown and text toggles
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
- ⚠️ **One pipeline, one delegate.** The viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the `sanitize-html.js` allowlist) and binds `_bindResponseViewerInteractions()` on `#filePreviewBody` (container-bound and idempotent, so once per page) for the code-block copy buttons and `a.rv-path` opening. Never a second parser, never a second click handler for the same markup. `breaks` is the one option that differs: chat keeps the default `true` (a newline is the agent's line break), while a file passes `false`, because a README hard-wrapped at 80 columns would otherwise render every wrap as a `<br>`, unlike GitHub's file view.
- ⚠️ **Build inside a `<template>`, then rebase.** A detached div whose `innerHTML` is set starts fetching every `<img src>` at once, so the document's relative image paths would hit the server as `/docs/img.png` 404s before being rewritten. `_rebaseFilePreviewMarkdownRefs()` runs on the template content: relative images go to the workspace-confined `file-raw` under the document's directory, root-relative ones (`/docs/x.png`) under the workspace root as on GitHub (the server refuses escapes, so `..` is forwarded as-is), and one `error` handler per image degrades it to alt text, which covers a remote image the page CSP blocks, a 404 for a document outside the workspace, and an SVG that `file-raw` serves as a download. Never widen a route for this. ⚠️ marked percent-encodes destinations (`my image.png` arrives as `my%20image.png`, CJK names as `%E5…`), so the ref is `decodeURIComponent`ed (a malformed escape is kept as written) and stripped of `#fragment` and `?query` BEFORE the route encodes it again; without that `file-raw` looks for a file literally named `my%20image.png`. Workspace links become `a.rv-path` with `data-path` AND `data-session-id` (the preview's session, which the `app.js` delegate prefers over `activeSessionId`, since a preview opened from another session's attachment card must resolve links against that workspace) and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment, protocol-relative and http(s) links are untouched. The absolute paths `_linkifyFilePaths()` then finds in the prose get the same `data-session-id`, set on every `a.rv-path` still lacking one. ⚠️ **An attachment opened under a bare file name has no directory.** Attachment cards and the history drawer call `openFilePreview(relativePath || fileName, sessionId, attachmentId)`, and a registered attachment's `relativePath` is always `''` (`attachmentRecordToEvent`), so `filePath` is just `report.md`: resolving against it sent `docs/report.md`'s `img/chart.png` to the workspace root's `img/chart.png` (a missing image) and its `CONTRIBUTING.md` link to the root's (a silently different file), and an out-of-workspace attachment's refs all landed in the workspace. `filePreviewText` therefore carries `attachmentId`, and when it is set with a non-absolute `filePath` the rebase degrades every workspace ref instead: images become their alt text (as a text node), links are unwrapped to their text. An absolute-path attachment (one `_registerExternalPreview` minted for a clicked path) still resolves against its own directory, as does every workspace preview.
- ⚠️ **`data-i18n-skip` on the container.** The translator's MutationObserver translates inserted headings and paragraphs, and the `.file-preview-content` entry in its skip list matches nothing (no element has that class), so the attribute is what keeps a Chinese UI from rewriting a README.
- ⚠️ **Toggles are per-device, in their own localStorage keys** (`codeman:filePreviewMdRendered` / `LineNumbers` / `Wrap`), for the same reason as the Files panel's show-hidden toggle: the app-settings object is rebuilt from the settings modal on every save, and they are display state, not synced settings (`SettingsUpdateSchema` is `.strict()`). MD re-renders from the kept source (`filePreviewContent`, which is also what Copy copies) without a refetch; Lines/Wrap are class flips on the one `<pre>`, whose rules are scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's own code blocks. Lines are one inline `<span class="fp-line">` per line joined by real newlines, the counter in `::before` with `user-select: none`, so select and copy return the exact text.
- ⚠️ **Caps.** Markdown fetches `lines=10000` (the route's `MAX_LINES_LIMIT`), because a rendered document cut at 500 lines reads as the whole document; other text keeps 500, which is what stops a huge log locking the tab in one `<pre>`. The attachment (out-of-workspace) branch keeps its 512 KB Range read and skips the 500-line clip for markdown. Edit mode is unchanged and still re-fetches `edit=1`; the three toggles hide while editing and for images, media and PDFs.
- ⚠️ **`md` is NOT in `FILE_PREVIEW_EXTENSIONS`.** Only an in-workspace `.md` path clicked in the terminal still opens the tail viewer (see File-path links above: in-workspace text keeps live follow, which is what Ralph's `fix_plan.md` needs). Everything else reaches `openFilePreview()` and renders it: the Files panel, a path clicked in the Response Viewer (its delegate calls `openFilePreview()` directly), an out-of-workspace terminal path, and attachment cards. `avif` and `ico` were added to the set (a printed `favicon.ico` used to tail binary noise), with `avif` also in file-content's image set and file-raw's MIME map; out-of-workspace avif/ico stay unregistrable, like svg/bmp.
Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above), `test/routes/file-routes.test.ts` (avif).
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`).
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL).
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
- ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`.
- **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there.
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
@@ -227,6 +416,8 @@ Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
### 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`.
@@ -261,6 +452,16 @@ Invariants:
**Self-update** (App Settings → System → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
Further detail on the Docker Compose supervisor (`docker-compose`), see also `docs/docker-self-update.md`:
⚠️ **The Compose deployment is the one supervisor that does NOT outlive the restart**: there the restart IS the container exiting (`restart: unless-stopped` relaunches it), which kills the script too — safe only because the terminal `restarting` marker is written BEFORE the kill, so nothing may be appended after it. Two config facts make it work at all and both are load-bearing: the repo is a HOST BIND MOUNT over `/opt/codeman` (a pull into the baked image copy would land in the writable layer and be silently discarded by the next `up`), and the runtime image keeps devDependencies + a build toolchain (`npm run build` is tsc+esbuild, and node-pty has no Linux prebuild), which is why `npm prune --omit=dev` is gone and the updater passes `--include=dev` against `NODE_ENV=production`.
⚠️ An in-place container update applies CODE ONLY — a restart reuses the existing image and config — so `evaluateEnvironmentGate()` REFUSES a release that changes `server.Dockerfile`/`docker-compose.yaml` (sha256 vs the baseline `Start-Codeman.sh` writes to `docker-env-applied.json` on every start) or adds `.env.example` keys the user's `.env` lacks, and refuses when the restart policy would not bring the container back. That third check exists because **Compose resolves an unset `${VAR}` to the EMPTY STRING and starts anyway**, so a new required setting otherwise arrives as a silently blank env var. Every unknown fails OPEN in the gate (no baseline, unreadable `.env`, no socket): failing closed would permanently block containers created before the fingerprint file existed.
⚠️ The KILL does not: the server exits only when `--restart-by-exit 1` was passed, i.e. the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (set ONLY there, since that file is what sets `restart: unless-stopped`; the image ENV deliberately does not) or the daemon reported an auto-restart policy; otherwise the build lands as `completed-needs-manual-restart`, because exiting blind takes a `docker run` container with no restart policy down with no UI left to recover it. The gate is re-evaluated on `POST /api/system/update`, so hiding the button is UX, not the control.
⚠️ The four global agent CLIs in `server.Dockerfile` are PINNED on purpose — unpinned, a user's CLI versions are a function of when their image was built rather than of any commit, which is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already catches. `test/docker-compose-env-parity.test.ts` is the merge-side guard (every compose `${VAR}` ↔ an `.env.example` entry).
### Web tabs
**Web tabs** (saved dashboard URLs rendered as tabs beside agent sessions; user guide `docs/web-tabs.md`). A webview is **NOT a `SessionMode` of its own**: no PTY, no tmux, no respawn, no idle detection. It is a separate resource (`~/.codeman/webviews.json` via `src/webview-store.ts`, types in `src/types/webview.ts`, limits in `src/config/webview-limits.ts`) that shares only the tab strip and the main content area, exactly as Docker and remote-SSH are case overlays rather than modes.
@@ -290,15 +491,169 @@ Invariants:
**Pre-existing bug fixed alongside**: `.toolbar` has `backdrop-filter`, which makes it a stacking context and TRAPS `.run-mode-menu`'s `z-index: 1000` inside it. With the toolbar itself at `z-index: auto`, `.welcome-overlay` (z-index 10, inside `<main>`) painted over the popped-up Run menu, making **every** item in it unclickable whenever no session was open. `.toolbar` now carries `z-index: 20` (must stay below `.modal`'s 1000).
Further detail: the refused egress targets are `169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, the Azure/Alibaba fixed metadata addresses and `metadata.google.internal`. The sandboxed-frame breakages (runtime root-absolute URLs escaping `<base>`, fixed by `runtimeUrlShim()`; same-host `fetch`/XHR CORS-checked with `Origin: null`, fixed by `buildProxyCorsHeaders()` plus the `OPTIONS`-204 exemption in `registerSecurityHeaders`) are two things `curl` can never reproduce.
⚠️ **A loopback link in agent output opens as a proxied web tab** (`openLinkThroughWebTabIfLoopback` in webview-tabs.js, called from the terminal link provider and the response viewer): a phone cannot reach the box's `localhost:5173`, so the tap routes through Codeman's origin instead, reusing a saved same-origin dashboard or saving one under `host:port`. Two rules there look cosmetic and are not.
**`*.localhost` is deliberately NOT in the auto-route set** even though it IS loopback to a browser: the link source is agent-written terminal output and prompt-injectable, every other member of that set is an address literal that can only mean this box, and a `*.localhost` DNS name is not one (a resolver with a search domain retries `evil.localhost` as `evil.localhost.<search domain>`, which an attacker can control, turning agent output plus one tap into a persisted server-side fetch of an agent-chosen origin). A user who really runs `api.localhost` saves it by hand, which is an explicit action. The PAGE-side test is deliberately broader (`isOnBoxHostname`), since a false positive there only declines to proxy.
**`this.webviews` being set does NOT mean it is loaded**: `initWebviews()` assigns a truthy EMPTY map synchronously and only then awaits the list, so a tap during page load must join the in-flight refresh (`_webviewsRefresh`/`_webviewsLoaded`) or it finds nothing to reuse and POSTs a duplicate record for an origin that already exists.
### Multi-user mode
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; shipped 1.5.0 via PR #161, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **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). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users/<name>/cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab, and the header **Admin Panel** button `#adminPanelBtn`: ships `btn-admin-panel--hidden`, revealed for admins in multi-user mode, phone-hidden via mobile.css; opens the full Admin Panel modal with user CRUD, per-user permission toggles, and case-folder list/delete via `GET/DELETE /api/admin/users/:username/cases[/:caseName]`; live-refreshes on SSE `admin:usersChanged`, wired in app.js). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
### Agent-created case marker
**Agent-created case marker** (`src/agent-case-marker.ts`): a case directory `POST /api/quick-start` **creates** for an agent-driven spawn gets a `.codeman-agent-case.json` marker, so the scratch workspaces a long orchestration leaves behind (one per worker, and deleting the session does not remove them) can still be told apart from the user's real projects months later. `GET /api/cases` publishes it as `agentCreated`; `GET /api/cases/agent-created` is the read-only cleanup listing, adding `inUse` (a live session's `workingDir` is that case) and `modifiedAt`; Add Case → Manage badges each one and offers a review-then-delete sweep. The signal is the skill preamble's `X-Codeman-Agent-Origin` header (or an `agentOrigin` body field), falling back to a RESOLVED `parentSessionId` — nothing in the browser UI sets lineage, so a create request naming its spawning session came from an agent by construction, and that fallback is what still labels workers spawned by a stale skill copy.
⚠️ **Only the branch that CREATES the directory may write it.** A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. `POST /api/sessions` takes an existing `workingDir`, so it writes no marker at all, by construction.
⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as *not* agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the `note` written into it tells whoever finds it.
⚠️ Removal stays on the existing `DELETE /api/cases/:name`, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an `inUse` case outright rather than confirming it away.
⚠️ Marker in the case dir rather than a registry under `~/.codeman`: it survives a wiped data dir or a different instance, is removed by the same `rm -rf` that removes the case (so no stale-entry pruning), and a user who runs `ls -a` can see what labelled their directory.
Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0) — a cached copy is version-checked, and forgetting the bump leaves every already-seeded agent sending the old headers. Tests: `test/agent-case-marker.test.ts`, `test/routes/agent-case-marker-routes.test.ts`.
### Docker Compose deployment
**Docker Compose deployment** (`docker/`, contributed): Codeman itself runs in a container and spawns Docker cases as **SIBLING** containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid *inside* Codeman means nothing to it. `resolveDockerDaemonMountSource()` translates sources under HOME into the daemon's namespace via `CODEMAN_DOCKER_HOST_HOME`, and `CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases).
⚠️ **`CODEMAN_CASES_PATH` must move every consumer or none**: it is resolved once in `config/cases-dir.ts` because `src/cli.ts` resolves case paths too, and when only the server's `CASES_DIR` learned the override, `codeman skill install --case <name>` reported "Case not found" on exactly the deployment the override exists for.
⚠️ **`.dockerignore` patterns match the WHOLE context-relative path**, so a bare `.env` line excludes only the ROOT file: `docker/.env` (which holds `CODEMAN_PASSWORD` and any provider keys) rode `COPY . .` into the image until `**/.env` was added — verified in both directions with a real build context.
⚠️ A Compose LONG-form bind (`type: bind`) **creates a missing host source directory ROOT-OWNED** rather than refusing. `Start-Codeman.sh` pre-creates both `CODEMAN_APPDATA_PATH` and `CODEMAN_CASES_PATH` on the host before `up`, which is what keeps the daemon from ever having to materialise either as root in the first place; the container ALSO starts as root (`cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against the base `cap_drop: ALL`; `test/docker-entrypoint.test.ts` pins that list) so `docker/entrypoint.sh` can correct a bind source that turns up root-owned anyway (a restored backup, a cleared directory, plain `docker compose up` run without the script) before dropping to `PUID:PGID` via `setpriv` — a directory owned by neither root nor `PUID:PGID` is never re-owned, since that ownership is not this container's to reassign; it is PROBED for writability as the runtime account (`setpriv ... test -w`, so ACLs, group-writable trees and CIFS/NFS mounts pass) and refused with a message naming path, owner and PUID:PGID if that fails.
⚠️ `KILL` is in that list for tini, not the entrypoint: `init: true` keeps tini as root while the server runs as PUID, and without CAP_KILL its SIGTERM forward fails and the server is SIGKILLed on every `compose down`/`restart` instead of flushing state.
⚠️ `/opt/codeman-cli` (the runtime-owned CLI prefix) and `~/.local/bin` (where Settings installs CLIs, on the home bind mount so they survive a rebuild) are APPENDED to `PATH`, never prepended, and the entrypoint pins its own `PATH` to the system dirs: the root part of the start resolves `setpriv` by bare name, and a prefix ahead of `/usr/bin` let a planted `setpriv` run as uid 0 (measured). `~/.local/bin` is writable from the host as well, and root `docker exec` and the healthcheck inherit the image `PATH` without the entrypoint's pin; `test/docker-entrypoint.test.ts` requires every `ENV PATH=` line to start `$PATH:`.
`CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies.
⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. User guides: `docs/docker-compose.md` + `docker/README.md`.
### DeepSeek web UI
**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**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab.
⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land.
⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success.
⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs.
### Custom Model Endpoint Profiles
**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET <baseUrl>/v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×.
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
**Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`) — re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item — confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed — its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), so a new `appendV1Suffix` flag on its registry entry (alone — claude/gemini must not get it) runs `endpoint.baseUrl` through `withV1Suffix()` before writing it, live-confirmed against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds) though not yet re-run through an actual `dsh` binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each.
⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default).
⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a merely remembered choice, and — only when nothing is currently loaded — the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied — never on the mere click — because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature.
Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open.
⚠️ The apply call reads the response body itself (`_api()`) rather than `_apiJson()`, which unwraps success but silently discards a failure body — losing the one thing (`error`) that distinguishes "still busy", "remote/Docker session" and everything else the route can report (⚠️ neither route validates `modelId` against the endpoint's discovered list, deliberately: discovery can be up to 5 minutes stale, so a 400 there would refuse a launch that works — a typo'd id fails on the CLI's own first request instead); the resulting toast is `type: 'error'` with an explicit `duration: 0` (no auto-dismiss, an explicit close button) at that one call site — not a blanket sticky-error default, which stacked unbounded on `.toast-container` with no cap or eviction — precisely so a message worth diagnosing survives long enough to be read instead of vanishing on the usual 3s timer. Entries are hidden for a remote/docker active case (the apply route refuses both) and for an endpoint with no discovered models at all (nothing to launch with).
⚠️ **Every saved endpoint's models also re-discover themselves automatically**, a `this.cleanup.setInterval` in `server.ts` (`CUSTOM_MODEL_REDISCOVER_INTERVAL_MS`, 5 minutes, off under `testMode` like the Codex plan-usage poll beside it) calling the exported `refreshAllCustomModelHosts()` (`custom-model-routes.ts`) — one endpoint unreachable on a cycle never blocks the others, and a read-modify-write PER HOST (re-reading the store before each splice, keyed by id) means an admin's concurrent edit or delete wins over a sweep that started before it, never the reverse.
**Later additions (llama-swap), each confirmed live against a real llama-swap deployment.**
⚠️ **llama-swap runs one model at a time, and switching can disrupt ANOTHER live session** — before applying, both apply routes call llama-swap's own `GET /running` (feature-detected via `getLlamaSwapStatus()`, `custom-model-routes.ts`; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is simply never checked). If a different model is loaded and ready AND another live session's own selection is using it, the apply returns `{requiresConfirmation, currentlyLoadedModel, affectedSessions}` instead of switching silently; retrying with `confirmedSwap: true` skips the check, and switching with nothing else affected proceeds immediately.
⚠️ **The swap question and the context-floor question below have SEPARATE flags** (`confirmedSwap`, `confirmedContext`), because the context check runs first and while both shared one `confirmed` a user who clicked past a too-small context silently consented to evicting another session's model too; the legacy `confirmed` still means both, since it shipped in the HTTP-API-only cut. llama-swap also has no dedicated "switch model" endpoint — the only thing that actually starts a swap is a real inference request naming the model (confirmed live: applying a selection alone never reached llama-swap's own logs, since nothing had asked it to load anything) — so both routes also fire `triggerLlamaSwapLoad()`, a fire-and-forget `POST <baseUrl>/v1/chat/completions` with `max_tokens: 1`, whenever the target model isn't already loaded and ready.
⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one.
⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all.
⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT.
⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasCompletedOnboarding` and this session's own `projects[workingDir].hasTrustDialogAccepted` merge into the same `.claude.json` the API-key trust file already writes to, and `skipDangerousModePermissionPrompt` merges into `settings.json` (a different file, same corrupt-tolerant merge).
⚠️ **The loading banner shows the REAL backend log line, not a guess, and has no countdown or auto-timeout at all.** `getLatestLlamaSwapLogLine()` holds one `GET /api/events` SSE connection open per endpoint (confirmed live to stay open indefinitely — read past 220KB over 8s with no `done`; idle-closed after 30s via `pruneIdleLlamaSwapLogTails`, same 20s sweep as the swap-displacement check above), parsing `logData` frames and keeping only `source: "upstream"` (the real `llama-server` process's own stdout) lines, never `source: "proxy"` (llama-swap's own request-access log). ⚠️ `GET /logs` — the endpoint this feature's own first cut targeted, since the name suggested it — was confirmed live to carry ONLY the proxy log and never a single backend line, even seconds after a real, verified model swap; caught and corrected by a live check before merge, not after. The banner itself dropped its size-scaled expected-time estimate and matching auto-timeout (a guess dressed up as a fact that could kill a genuinely slow load partway through on slower hardware) for a generic hardware/model-size disclaimer plus a user-driven **Cancel** button (`_showCenterStatus`'s `onCancel` option, a real button distinct from the plain "×" close glyph an `'error'`-type banner gets) that ends the wait and closes the session on the user's own call rather than a guessed deadline.
### Approvals Inbox
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409).
⚠️ Resolution on the heuristic `working` signal ALONE is restricted to `idle` items; a permission/question item gets the pane-VERIFIED variant on that same signal (`resolveIfDialogGone()` → `verifyStillAnswerable()`), so the heuristic only decides when to LOOK and the screen decides the outcome. That is what clears a dialog answered in the terminal mid-turn; the other definitive signals are `stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede and the 12h TTL.
⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert.
⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the four selections the APP makes (boot restore, a solo window opening its target, a `#session=<id>` link from another page, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it.
⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it.
⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one).
⚠️ **`applyCapture()` is therefore ADD-ONLY for `options`**: a re-capture that parses nothing must never erase a parse an earlier one found. Claude Code delays the Notification hook behind the dialog (measured 6s, documented ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered; clearing the field there made the item permanently unsweepable, because `verifyStillAnswerable()` reads a MISSING `options` as "we never could read this dialog" and keeps such items answerable by design. The red "needs you" then survived every sweep AND every page reload, went away only on `stop` (2026-08-20: a confirmed question left a tab flowing red for ~8 minutes while the turn ran on), and the stale card still accepted an answer, typing a bare `1` into a composer with no dialog under it. Pinned by `test/approval-inbox.test.ts`.
⚠️ A frame that parses no options is CONCLUSIVE in exactly two cases, and the second one closes the late-hook hole: the item once parsed options (they cannot vanish while the dialog is up), or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist — measured on v2.1.237, a live-dialog frame carries neither the `… (13s` timer NOR the `esc to interrupt` footer, which the dialog replaces with `Enter to select · ↑/↓ to navigate · Esc to cancel`. Anything else stays answerable, so an unreadable capture still keeps the alert. That second signal is reached by a delayed staleness pass (`STALE_CHECK_DELAY_MS`, 3s) scheduled alongside the re-capture, because a prompt answered BEFORE the hook lands creates an item whose FIRST capture already has no dialog in it: nothing ever parsed, `stop` may have fired already, and the alert then outlived reloads until the 12h TTL.
⚠️ That pass must stay comfortably LATER than `RECAPTURE_DELAY_MS`, whose whole reason for existing is that the hook can beat Ink to the screen — resolving inside the paint window would clear the alert for a dialog that was about to appear. The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason.
⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
### Read My Mind intent profiles
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics).
**Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided).
Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
### Voice dictation via Claude
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own `/voice` mode uses.
⚠️ **Claude Code's voice mode itself is unusable here**: it opens the HOST's microphone (`sox`/`arecord`), and the CLI runs in a headless tmux pane while the human is in a browser elsewhere. So Codeman captures in the browser and borrows only the backend. Audio goes browser → Codeman → Anthropic (`src/web/voice-stream.ts`): the OAuth token never reaches the page, and the browser only sends PCM and receives text.
⚠️ Credentials are **read-only** (`src/claude-credentials.ts`) and Codeman never refreshes them — a refresh rotates the refresh token and could sign the user out of their own CLI; an elapsed token reports `expired` instead.
⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**.
⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
### Files panel search
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree.
⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested.
⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running.
Matching semantics: a query containing `/` matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
### Raw file bodies: streamed and range-aware
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416).
⚠️ **The size cap on all three is a sanity bound, not memory protection** (`MAX_FILE_DOWNLOAD_BYTES` in `config/buffer-limits.ts`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited): the bodies stream, so size costs a read stream and not RSS (measured: a 600MB download moved peak RSS by ~37MB). Its predecessor was a hardcoded 50MB whose comment still said "prevent memory exhaustion" long after the `readFile()` it described was replaced by `sendFileBody()`, so all it did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file, and now shares `sendFileBody()` with the other two.
⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all.
⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file.
⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
## Frontend
### 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. ⚠️ `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.
**Terminal smart copy** (#211): `Ctrl+C` copies the selection when there is one and stays the interrupt when there isn't. Three rules keep that split honest, and breaking any of them silently costs the user their interrupt key:
@@ -308,11 +663,12 @@ Invariants:
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run. Three rules keep it honest:
**The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest:
1. **Trailing padding only. A shared LEADING indent is deliberately NOT stripped**, and that is a decision rather than an omission: it was built, measured and dropped before #451 merged. It looks like the mirror image of the trailing trim and is not, because no native terminal does it and the transform cannot tell a TUI's margin from content that is genuinely indented. Measured over 401,445 three-row windows across 1,010 tracked files in this repo it fired on **73%** of them (92% inside a YAML workflow, 76% over `git log` output, 48% in a TypeScript source), and no width threshold separates the two because they are the same widths: a live Claude Code pane's own margins measure 2 and 5 columns while the most common non-TUI shared run is 4, sitting between them. The failure modes are what settle it. A wrong trailing trim costs nothing; a wrong dedent silently deletes information that was on screen, with no signal and nothing in the clipboard to hint at it, and it is wrong on `git log` bodies, on indented code read out of `cat` (semantic in Python), on `git diff` context rows where the leading space is the marker, and on stack traces. ⚠️ It also could not be made self-consistent cheaply: whether the first row joined the measurement depended on the mousedown COLUMN, which the user never sees, so one block of three rows produced three different clipboard results, and the flag read `getSelectionPosition().start`, which is the mousedown anchor xterm never normalises, so dragging UP through a block read it off the bottom row (the PR's test stub hardcoded a downward drag, so its suite could not express the case). If it is ever revisited, the one qualification that measured clean is **painted trailing padding** (a full-screen TUI writes real spaces across every row, while a shell pane leaves those cells never-written for xterm to trim): zero false positives over all 401,445 windows, no new plumbing. It still mangles a `git log` body sitting inside an agent's own gutter, which is why it was not taken.
1. **A leading strip uses the width the CLI DECLARES, never one derived from the pane.** `capabilities.transcriptGutter` (CLI registry, a bounded integer) is the whole source: claude and codex each declare 2, no other stock entry declares any, and an entry that declares nothing never has a margin taken off a copy. ⚠️ The lookup keys on the session's LAUNCH mode, not on what is running in the pane: a claude-mode session whose pane has dropped to a plain shell (the CLI exited, or a shell was started inside it) still takes up to two columns off a copy, so a `cat`'d block indented four comes out at two. The ceiling rule below keeps its relative indentation intact and `copyStripMargin` (App Settings, "Trim the pane margin on copy") is the escape hatch, so this is a known limitation, not a guarantee the strip only ever fires on a transcript. The server publishes the map as `window.__codemanTranscriptGutter`, built by filtering `enabledClis()` on that capability rather than by listing ids, and `_cliGutterColumns()` (terminal-ui.js) looks that session's mode up in it, defaulting to the active session and taking an explicit id for Pane B of a split. ⚠️ **Three ways of deriving the width from the text were built and measured, and two of them shipped looking correct.** The selection's own shared indent fired on **73%** of 401,445 three-row windows across 1,010 tracked files, because a three-row window of nested YAML shares an indent for the same reason a margin does. **Painted trailing padding** — a full-screen TUI writes real spaces across the part of a row it is not using, while a shell leaves those cells never-written for xterm to trim — has no false positives and never over-stripped, and is nonetheless a function of pane WIDTH: the padding exists only while a rendered line stops short of the CLI's own layout width, and Claude Code's prose wraps to fill it. Dragging the same two prose rows of one live transcript at five window sizes, the share of rows carrying padding measured 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298 columns, so at every ordinary size the strip did nothing at all while every test and every 282-column measurement said it worked. The **narrowest indent on the surrounding rows** fires at every width and over-strips about 1% of selections, because a file listing inside the transcript can be the narrowest thing on screen. ⚠️ The declared width is a **CEILING, not the answer**: `clean()` strips the lesser of it and the run every selected line shares, so a block can only ever shift as a unit, the relative structure inside a selection survives by construction, and a selection reaching column 0 loses nothing. That is what keeps a `git log` body at its own four spaces inside an agent's two-column gutter. Measured over 1,392,281 selections — every 1, 2, 3, 5, 10 and 20-row window of real Claude screens replayed from live PTY streams at 100, 120, 160, 198, 235 and 282 columns — it over-strips none, breaks no relative indent and alters no text, at 100% of the selections whose own indent covers the gutter. ⚠️ Nothing reads the terminal buffer on this path; the old version scanned up to ~240 rows per `Ctrl+C` to measure a width the CLI can simply state. ⚠️ **The margin strip is not idempotent, so no caller may clean twice.** It takes the lesser of the declared width and the shared run, so a second pass takes up to `margin` columns more off whatever the first left. The trailing trim alone is a fixed point, and `copyTerminalSelection()` relied on that by re-cleaning whatever argument it was handed, which dedented every claude and codex copy twice on the `Ctrl+C` path — the most-used of the four, while right-click, the phone selection button and Auto Copy stayed correct because each hands over the raw selection or reads it live. The gate still tests the CLEANED string, so a padding-only selection still falls through to the PTY, and the RAW string is what travels on. A source-level test pins that branch, because it lives inside `initTerminal`'s `attachCustomKeyEventHandler` closure over a real xterm that the vm harness cannot build.
2. **A COLUMN selection is returned untouched.** Alt+drag makes one (xterm's `shouldColumnSelect` keys on `altKey` alone, and neither `Terminal` Codeman builds passes the one option, `macOptionClickForcesSelection`, that would disable it), and a rectangle's rows lining up is the whole point of the gesture. xterm exposes the mode nowhere public, so the check reads `terminal._core._selectionService._activeSelectionMode` (`SelectionMode.COLUMN` is 3) and cleans normally if a future xterm renames it.
3. **The emptiness gate is `trim()`, not truthiness, and it still clears the selection.** A multi-row drag across padding cleans to line breaks alone, which are truthy, and a bare newline pasted into a chat composer submits it. ⚠️ The clear is FEEDBACK, not protection for the interrupt, and the comments that said otherwise were describing the pre-clean code: the `Ctrl+C` gate above now tests the CLEANED selection, so a padding-only selection left set cleans to `''` on every later press and falls through to the PTY as `0x03` anyway. What the clear buys is that a highlight which copied nothing does not linger unexplained, which is also what the toast is for.
3. **The mid-row flag reads an ORDERED range, and it governs one line.** ⚠️ `getSelectionPosition()` is reversal-aware on the pinned xterm and the review note that called it the raw mousedown anchor is out of date: `CoreBrowserTerminal.ts` reads `_selectionService.selectionStart`, whose getter returns `SelectionModel.finalSelectionStart`, and that swaps the pair when `areSelectionValuesReversed()` says so. Driving a real upward mouse drag through chromium against xterm 6.0 reports the same range as the downward drag of the same rows. `_normalisedSelectionRange()` orders the pair anyway, because the private model one layer down exposes unnormalised fields under the same two names, and a reversed pair would put the mid-row flag on the wrong end of the drag. `range.start.x > 0` then means the first selected line began mid-row and never carried the margin, so that line alone is left out of both the shared-indent ceiling and the strip. ⚠️ This is the ONE thing the mousedown column still decides and it decides it for that line only. In the dropped version it decided whether the first row joined the measurement, which changed what **every** row lost, so the same three rows produced three different clipboard results depending on where the click landed.
4. **The emptiness gate is `trim()`, not truthiness, and it still clears the selection.** A multi-row drag across padding cleans to line breaks alone, which are truthy, and a bare newline pasted into a chat composer submits it. ⚠️ The clear is FEEDBACK, not protection for the interrupt, and the comments that said otherwise were describing the pre-clean code: the `Ctrl+C` gate above now tests the CLEANED selection, so a padding-only selection left set cleans to `''` on every later press and falls through to the PTY as `0x03` anyway. What the clear buys is that a highlight which copied nothing does not linger unexplained, which is also what the toast is for.
Tests: `test/terminal-copy-clean.test.ts`.
@@ -378,6 +734,8 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
`data-claude-only` lives on the **rail entries**, so external-CLI sessions lose Respawn and Ralph and open on Session (`switchOptionsTab('context')`, verified in the browser). `admin-ui.js` injects the multi-user Users entry as a `data-section="settings-users"` rail button plus a `#settings-users` section appended to `.set-rail-items` / `.set-doc`, so those two hooks must survive any restructure.
Further detail: in the Settings section order, the version and the updater lead the document and the rest of the system settings tail it. ⚠️ Model cards (`#appSettingsModelCards`) and the effort segment are **views over hidden `<select>`s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. Without the explicit chevron on the Add Case adapter's `<details>`, the five collapsed blocks render as plain headings nobody clicks.
### WebGL renderer toggle
**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
@@ -402,6 +760,28 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
⚠️ Collapsed means **different things per viewport**: at 1024px and up the sidebar keeps a 44px icon rail so the ambient signal (status dot, task/subagent/ultracode badges) survives — the Alt+N number, the name/folder and the `sh`/`oc`/`cx`/`gm` mode chip do NOT, because 44px minus paddings and borders is ~34px of content box and the chip lives inside `.tab-info`; below 1024px `mobile.css` turns the sidebar into an off-canvas overlay where collapsed == drawer closed (mirrored into an `.open` class plus `inert`/`aria-hidden`, since `translateX(-100%)` alone leaves every row in the Tab order), it defaults to CLOSED when the user has made no choice, and picking a session or web tab dismisses it. ⚠️ **That 1024px breakpoint is the only handheld test the sidebar may use** (`_isSessionSidebarOverlay()`, mirrored in the pre-paint script): `MobileDetection.getDeviceType()` calls everything from 768px up `'desktop'`, so using it gave 768-1023px the overlay CSS with docked-sidebar logic — drawer opening itself on load, immune to selection and Escape. The toggle chord (default Alt+B) also needs its gate in `terminal-ui.js`'s `attachCustomKeyEventHandler`, or `preventDefault()` in the capture handler still lets xterm write ESC b into the live PTY (same trap as COD-153). The sidebar filter only applies while its input is on screen — `applySidebarFilter()` strips the class in the header strip, the collapsed rail and the closed drawer, because a filter with no reachable control hides sessions permanently. Collapse state lives in its OWN `codeman-sidebar-collapsed` key, **not** in the settings blob — `saveAppSettings()` rebuilds that blob from DOM controls, so a key without a control is wiped on every Save. Solo (`/session/:id`) windows never get a sidebar (three guards: `getSessionListLayout()`, the pre-paint script, and `body.solo-mode`), because `#sessionTabs` parked in a `display:none` subtree measures 0/0 for tab overflow and inline rename. The sidebar CSS block sits at the END of `styles.css`, **after** the `html:not([data-skin="og"])` nesting block, and is layout-only — any colour on `.session-tab` there would render correctly on the `og` skin only. Same for the `mobile.css` block: it must stay at the end of the file or the earlier compact-strip rules clip the list to a 36px sliver. Two surfaces DEFER to the sidebar rather than adapt: **lineage arcs are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns — `computeLineagePath()`'s whole geometry hangs a U-bridge from the horizontal STRIP's bottom edge, so against a vertical list every arc would loop to the foot of the sidebar; a sideways lineage shape needs its own visual tuning, it is not a by-product of re-parenting), and the **desktop home tab rail** (`shouldShowHomeSessions()`) stays hidden while the sidebar is active, because both dock the session list flush left and the rail would render the same list next to it, z-ordered UNDER it. The subagent/ultracode connectors DO adapt (`_tabAnchor()`/`_tabConnectorPath()` in app.js: right-edge anchor, horizontal bezier), and the lineage strip-scroll listener redraws them on the sidebar's vertical scroll. `_scrollActiveTabIntoView()` owns active-row reveal on BOTH axes: sidebar mode branches to `scrollIntoView({block:'nearest'})` because the horizontal `computeTabScrollLeft` math no-ops against a vertical scroller, and `_fullRenderSessionTabs()` restores `scrollTop` alongside the #257 `scrollLeft` restore or ambient rebuilds yank a mid-scroll sidebar back to the top. Tests: `test/session-list-layout.test.ts`.
Further detail: with many sessions the horizontal strip stops being scannable, which is why the list can move into the vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). The setting lives in App Settings → Appearance → Tabs. There is a THIRD host for `#sessionTabs` besides `#sessionTabsHost` and `#sessionSidebarList`: `#tabRail`, the vertical rail. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ `applySessionListLayout()` sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes.
⚠️ **The vertical tab rail** (`tabOrientation`/`tabRailWidth`/`sessionSidebarFontSize`, all per-device display keys that ARE in the schema, like `sessionListLayout`) is a SECOND vertical list next to the sidebar: the orientation setting is silently ignored while the sidebar layout is chosen, desktop/tablet only (`resolveTabOrientation` forces horizontal on mobile), resizable via `tab-rail-resize.js` (which owns terminal refits during the drag). The pre-paint script stamps `data-tab-orientation` (+ `--tab-rail-width`) like it stamps the sidebar keys, or vertical mode flashes through the header strip; the name font size defaults to 12px, the sidebar's historical size, so untouched installs are never restyled.
⚠️ **Detailed rows are a property of a vertical LIST, not of one surface** (`sessionListLayout: 'sidebar-rich'` for the sidebar, `tabRailDetail: 'rich'|'simple'` for the rail, rail default **rich**): both draw the home screen's per-session line (`created 3d ago · working 12m`) plus a status pill, from the SAME row model (`_sidebarRichRow`/`_sidebarRichMetaHTML` in app.js, classified by `_mobileOverviewState`/`_mobileOverviewSince`), and the render paths ask ONE gate, `isRichTabRows()` (= `isSessionSidebarRich() || isTabRailRich()`). ⚠️ Detail rides on its own attribute (`data-sidebar-detail` / `data-tab-rail-detail`) so every existing `[data-session-list="sidebar"]` / `[data-tab-orientation='vertical']` rule keeps matching both variants untouched; a flip of detail ALONE still needs a full render (the stamps line is emitted by the row template, not toggled by CSS) and must re-run `applyTabWrapSettings()`, which owns the folder line and is now rail-aware. ⚠️ The rich CSS rules carry a rail twin as a COMMA-GROUPED selector, never `:is()` (an `:is()` list takes its most specific argument, which would lift the sidebar arm from (0,3,1) to the rail's (0,5,1)).
⚠️ Width is the whole reason there are thresholds: the rich sidebar is 300px (`--sidebar-width-rich`) and a rail that has never been sized defaults to **320** (`RICH_DEFAULT_WIDTH`, the existing Wide preset) instead of 256, because at 256 the stamps line ellipsizes mid-word; a user-narrowed rail drops the created stamp below 288 (`tab-rail-tight`, CSS only) and drops rich rows entirely below 240 (`tab-rail-compact`, which re-renders). A stored width is never overridden. ⚠️ Only detailed rows carry stamps that go stale with no event behind them, so `_startSidebarRichClock()` (20s, rewrites text in place — a re-render would restart every row's animation) must be armed and disarmed by BOTH `applySessionListLayout()` and `applyTabOrientation()`.
⚠️ **Axis decisions must use `_isVerticalTabList()`** (sidebar OR rail), never `isSessionSidebarActive()` alone: the rail leaves `data-session-list` at `header`, and the sidebar-only predicate shipped four rail bugs at once (drag insertion side read from clientX, active tab never scrolled into view, floating windows anchored below tabs instead of beside them, connector redraws skipped on rail scroll).
⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the closed drawer keeps `display: flex`, so without `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable.
⚠️ **The detailed RAIL additionally wears the home rail's CARD, and the detailed SIDEBAR deliberately does not**: the rail is an occasional, resizable list you scan, the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall — so the card rules are RAIL-SCOPED and were NOT added to the comma-grouped selectors above, which is the one-line change that would silently restyle the sidebar. Card state accents reuse `home-sessions-blink-red`/`-yellow` rather than declaring a second pair, and every state dot rule excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and the rail's are (0,5,1)+. There is no `.active` rule in that block on purpose: `.session-tab.active` already paints border/background/box-shadow `!important`.
⚠️ **Row ORDER is a third rail attribute** (`tabRailSort: 'activity'|'manual'`, `data-tab-rail-sort`, default **activity**): a sorted rail answers the home screens' question with the home screens' answer, `CodemanSessionOrder` over rows classified by `_mobileOverviewState` (`_tabRailSortOrder` in app.js). ⚠️ **It is applied as the flex `order` property, never by reordering the DOM**: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badge (`_tabIdx`, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS `order: 9999` rather than an inline one, since `renderWebviewTabs()` emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ `setupTabDragHandlers()` sets `draggable="false"` and returns while sorting is on: the drop rewrites `sessionOrder` correctly and the sort then puts the card straight back, so the affordance would be a lie — `'manual'` is the way back to drag-reordering.
⚠️ **The arrow-key walk is the one place that must follow the SORT rather than the DOM**: `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED `order` first (computed, not inline: web tabs get their `order: 9999` from CSS and would otherwise read as 0 and lead the walk). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the thing the user steps with their eyes follows the paint.
### Split-pane sessions
**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without an explicit `this._splitPane?.fit()` call there Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket leaves Pane B visibly dead (a message written into its own xterm buffer) rather than silently swallowing keystrokes with nothing on screen to explain why — there is no reconnect logic for v1, matching the "deliberately plainer than Pane A" design. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, tmux `send-keys -H` for a real 0x0a) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell straight to `return trLine truncated
### Gesture control: the setting
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Terminal & Input → Scrolling & rendering (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
@@ -436,6 +816,158 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
**The hinge is a reserved region, and the CSS is inert by construction.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent; `--fold-inline-end` / `--fold-block-end` (end of styles.css) measure the strip to keep clear from the LEADING segment (`env(viewport-segment-right 0 0)` and `env(viewport-segment-bottom 0 0)`, physical sides in every text direction) and are `0px` on everything else. The seven centred overlays are all `position: fixed; inset: 0` flex boxes; each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. The cascade traps, each measured in headless Chromium against styles.css + mobile.css in index.html link order: (1) a later `padding-right` longhand beats the earlier `padding` shorthand it composes with, so each fold rule re-states the overlay's own gutter; (2) a base gutter that a LATER `@media` block overrides needs its own fold restatement in that block on a ZERO base, since the unconditional rules at the end of the file otherwise put the gutter back (the phone path picker and path preview drop to `padding: 0` under 600px and came back at 16px and 18px on every phone); (3) a compound rule written to outrank a mobile.css shorthand must be scoped to the band where that shorthand applies, because outside it there is no gutter to compose with (the palette's `.modal.command-palette-modal` added 0.75rem at 393, 900 and 1400px and pushed the shell 6px off centre, and inside the band it lost its bottom gutter to the shorthand until it restated that side too); (4) mobile.css loads AFTER styles.css, so a same-specificity rule there wins under its own breakpoint (the response viewer's `max-height: 92dvh` under 600px beat the tabletop cap, which now has a twin at the end of mobile.css). `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet, simulates the cascade across both files at every breakpoint with and without the fold rules, and requires the two results to differ by exactly the fold strip, so each of the four fails there instead of on hardware nobody has.
Further detail: the design follows Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo). The sticky keyboard latch survived until the device was opened again, and rotating any phone hit it too. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. The palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px; its compound rule lives INSIDE the 600-768px band whose mobile.css shorthand it composes with. The fold measurements were taken at 393 and 500px widths. Device profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`.
### Terminal touch gestures: link taps and text selection
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly.
⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog).
⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert.
⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone).
Tests: `test/terminal-touch-tap.test.ts`.
### Entrance animations
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart.
⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without.
⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement.
⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong.
⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target.
### Mobile tab strip scrolling
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the phone block (under 600px), because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it).
⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end.
⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
### Phone overview home screen
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based.
⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list.
⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
### Desktop home tab rail
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **overview order** (see Home-screen session order), and each carries a **created** stamp plus the **state duration** the order is computed from (`created 3d ago · working 12m`, word and anchor from `_mobileOverviewSince()` so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now".
⚠️ The number badge is the **Alt+1..9 index**, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it.
⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent.
The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
### Home-screen session order
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure + unit-tested in `test/session-overview-order.test.ts`): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is `needs` → `error` → `waiting` → `working` → `idle` → `done`.
⚠️ **The tiebreak flips direction halfway down**: states a session is still IN sort **oldest-first** (blocked longest / running longest = most urgent), states it has STOPPED in sort **newest-first** (the session that just went quiet is the one you came back for).
⚠️ The running group keys off **`lastSubmitAt`** (the pane's last Enter), never `lastActivityAt`: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it.
⚠️ A **0 stamp means "unknown", not "the epoch"**, and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (`orderIndex`), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
### Terminal font weight
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no colour change, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves both slots, each against **its own** xterm default, so an unset bold weight can never inherit `normal`.
⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range).
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates).
⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
### Shell keyboard accessory bar and one-shot Ctrl
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice.
⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route.
⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed.
⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte.
⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke.
Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard.
⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
### Mobile prompt composer
**Mobile prompt composer** (PR #444, the first slice of #359, `keyboard-accessory.js`): the agent bars' Paste key is now **Compose**, a dialog with a native multiline textarea (autocorrect, autocapitalize, spellcheck) where Enter adds a line and only **Send** submits; the shell bar keeps the direct Paste dialog, since shell input is not an agent prompt. Opening it ADOPTS the whole editable terminal prompt (`_takePendingLocalEcho`): the local-echo overlay's pending text has never reached the PTY, but the flushed prefix has, so that prefix is erased with backspaces counted in CODE POINTS (`Array.from(text).length`; measured on Claude Code 2.1.278, `a` + emoji + `b` takes three, and the UTF-16 count sent four and ate the neighbour; `clearTerminalInput()` in terminal-ui.js moved with it).
⚠️ **Drafts are per-session and in memory only** (`_composerDrafts`, never persisted: prompts routinely carry secrets, and persisting them would need the 0600 treatment the intent store gets). Every non-Send exit (Cancel, backdrop, Escape, "Use terminal keyboard") leaves the taken text ONLY in the draft, with the dot on the key (`has-draft`, kept in step by `_syncComposerDraftIndicator`) as the signal that the terminal prompt is empty on purpose, and `_cleanupSessionData` discards the draft with the session.
⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`).
⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them.
⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast.
⚠️ `.prompt-composer-overlay` is a `.paste-overlay` with a gutter of its own (a `padding` shorthand whose bottom is 12px plus the safe area), and the unconditional `.paste-overlay` fold rule at the end of styles.css is a later longhand at the same specificity, so it ERASED that gutter (measured at 393x852: `padding-bottom: 0px` flat, and the hinge strip REPLACING the gutter with the fold variables set): the composer has its own restatement after the fold rules, the dialog's `max-height` subtracts `--fold-block-end`, and `test/foldable-layout.test.ts` lists the composer in `ELEMENTS` by hand, because its derived overlay list keys on rules that declare `position: fixed; inset: 0` themselves.
Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not under `test/mobile/**`).
### Dismissing the on-screen keyboard
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions.
**(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`.
**(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`.
⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug.
⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
### SSE staleness watchdog
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads.
⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type.
⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight.
⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for.
⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
### PTY and browser terminal geometry
**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to reach it — output visible, typing invisible, for as long as the claim stayed hot. Width is the axis the wrap arithmetic depends on; rows only decide how much is on screen, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it).Line truncated
### Terminal resilience: replay clears, renderer liveness, fetch deadlines
**Terminal resilience: replay clears, renderer liveness, fetch deadlines**: three rules that each close a way the terminal silently stops being correct. ⚠️ **A replay clear MUST be in-stream, never `reset()`/`clear()`.** xterm's `write()` is asynchronously queued while `Terminal.reset()` is synchronous and, per upstream, "does not clear input buffers and does not reset the parser" — so bytes queued just before a reset are parsed AFTER it and fuse into the snapshot written next. **Measured** against the real xterm in this repo: `write('p8'); reset(); write('rmissions')` renders `p8rmissions`; the queued `\x1bc` renders `rmissions` and clears scrollback. `_resetTerminalForReplay()` (app.js) is the ONE clear, a single queued `\x1bc` (RIS), and all three replay paths go through it; RIS rather than `\x1b[3J\x1b[H\x1b[2J` because the erase leaves modes, charsets, scroll regions and SGR state alone. Callers may still chunk the content — ordering in the queue is what matters, not writing it in one call. ⚠️ **The renderer watchdog reads xterm privates and CANNOT be covered by the gate.** `_kickRenderer()` (terminal-ui.js) cancels a stale `_core._renderService._renderDebouncer._animationFrame` and forces a repaint. **Verified against xterm 6.0.0** (jsdom, after `open()`): the field path resolves, a forced stale handle genuinely makes `refreshRows` a no-op, and the kick schedules a fresh frame. **Reasoned, not reproduced here**: the premise that iOS discards scheduled rAF callbacks when a PWA backgrounds, which is what leaves the handle stale — that half wants a real-device pass. Codeman has exactly ONE xterm for the whole page load, so one backgrounding would wedge it until a reload. `_renderService` only exists after `open()`, which needs a real DOM, and the gate runs in node — so `test/xterm-private-api.test.ts` pins the RESOLVED lockfile version (not the `^6.0.0` range, which a real upgrade slips through) and a bump means re-verifying by hand. Every access is optional-chained on purpose: a renamed field must degrade to a no-op, never throw on a 2s timer. ⚠️ **Every terminal capture carries a deadline, and the helper reads the BODY** (`_fetchTerminalCapture`, app.js). `await fetch()` settles on response HEADERS, so clearing the timer there leaves the body — the multi-megabyte `?full=1` capture this exists for — unbounded: **measured** at 4026ms under a 1000ms deadline before the fix. The helper therefore returns `{json, headers, headersAt}` rather than a `Response`, and `_terminalCaptureInflight` is scoped the same way so a body still streaming counts toward a capture starting beside it. It degrades to a plain fetch where `AbortController` is missing — the deadline is a safety net, not a dependency. Tests: `test/terminal-resilience.test.ts` (pure decisions), `test/xterm-private-api.test.ts`.
### WebSocket output-gap reconcile
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): terminal OUTPUT frames carry no sequence number (input frames do — `seq`+`cid`, at-most-once, ACKed), so a dropped socket leaves a hole nothing replays. ⚠️ **The gap is narrower than "the device went offline"**: if the network drops, SSE drops with it and `handleInit`'s keepTerminal branch already calls `_onSessionNeedsRefresh`. The uncovered case is the WS dying while SSE stays up (half-open socket, proxy idle-timeout, ping timeout), because `_onSSETerminal` discards every SSE terminal frame while `_wsReady` is true and `_wsReady` only flips in `ws.onclose`. Reaching `onclose` at all means the drop was unintentional (`_disconnectWs` nulls the handler first), so the session is marked and the next successful open reconciles. ⚠️ **The marker must be cleared by every path that repaints that session's buffer, and ONLY once one actually has.** `_markTerminalBufferReconciled()` is called from `selectSession` after its load, from `_cleanupSessionData`, and from `_onSessionNeedsRefresh` **at the repaint itself, not in its `finally`** — clearing on every exit meant a reconcile that threw, or hit the fetch deadline (the flaky link the marker exists for), dropped the gap with nothing to retry it. `ws.onopen` no longer clears it up front either, so a reconcile that is skipped or fails is tried again on the next open; re-entry is safe because `_terminalRefreshOwner` makes a second reconcile for the same session a no-op. `selectSession` loads the buffer and only THEN calls `_connectWs`, so without that clear the socket opening afterwards replays the whole buffer a second time on top of the one just written. Sequencing the output frames is the real fix and is not done. This is reasoned from the code path, not observed on a device.
### Service worker precache and cache key
**Service worker: precache and cache key are BUILD-GENERATED** (`sw.js` + `scripts/build.mjs`): the build content-hashes assets and rewrites two exact declarations in `sw.js` — `const BUILD_ID = 'dev';` and `const HASHED_ASSETS = [];`. ⚠️ **Each must appear exactly once or the build THROWS**, which is deliberate: the list used to be hand-maintained with PRE-hash names, so every entry 404'd in production and `cache.add().catch(() => {})` hid it (15 of 23 verified failing against a running instance). The dev literals are valid on their own, so dev serves an unrewritten worker with an empty precache. ⚠️ **`caches.match` must pass `ignoreSearch: true`**: `renderIndexHtml` runs `cacheBustAssets`, which appends `?v=<mtime>` to every same-origin `.js`/`.css` reference INCLUDING content-hashed names, so the page requests `/app.<hash>.js?v=<mtime>` while the cache holds `/app.<hash>.js`. Without it no precached entry is reachable and the install downloads ~1.3MB that can never be served — once per deploy, since `CACHE_NAME` now carries the build id. That per-build key is what makes `activate`'s cleanup actually delete anything; it used to be the constant `'codeman-v1'`, so assets from every past release accumulated forever. Contract pinned by `test/sw-precache-manifest.test.ts`, which PARSES the `HASHABLE` list out of `build.mjs` rather than copying it.
### Z-index layers
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
## Security layers
### Layer-by-layer detail
@@ -460,6 +992,16 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
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. **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`.
### Process-tree walks are bounded
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path).
⚠️ **The unbounded version took a machine down** (2026-07-30): it ran `pgrep -P <pid>` once per node and recursed with no visited set, no depth limit and no node cap, so across ~28 adopted tmux trees the fan-out exploded while each `pgrep` blocked in the WSL kernel reading `/proc/<pid>/cgroup`, ending at ~13,000 `pgrep` processes in D-state, a load average above 13,000, and a machine recoverable only by restarting WSL, which cost every running session. Three properties make that impossible and each has a test: a cycle terminates (a real tree has none, a stale snapshot can still produce one), depth is capped (`PROC_WALK_MAX_DEPTH`), node count is capped (`PROC_WALK_MAX_NODES`). The fourth is structural: the function takes a snapshot and cannot spawn anything at all.
⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots.
⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
## Local packages and build artifacts
### xterm-zerolag-input is single-source
@@ -481,3 +1023,35 @@ The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/
### Headless screenshot capture
**Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-<ts>.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
### Test isolation: tmux, docker and HOME
**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `Session` is test-gated too: instead of attaching a real tmux client, it spawns a raw-mode echo PTY (`TEST_PTY_SCRIPT` in `src/session.ts`), so integration tests get a live input/output loop that echoes each byte exactly once.
`test/setup.ts` gives every test file a temporary `HOME`/`USERPROFILE` (all `homedir()`-derived state, `~/.codeman` and `~/codeman-cases` included, resolves into a per-file fixture; the Playwright browser cache path is preserved), and additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions), and strips the three instance-selection vars `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (#356/#371; `test/test-env-isolation.test.ts` pins the list, and its STATIC half reads setup.ts so a dropped `delete` fails everywhere rather than only on a box that exports the var).
⚠️ `CODEMAN_DATA_DIR` is the one that matters: `getDataDir()` reads it as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run, a shell left over from `codeman web -d`) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else; `CODEMAN_INSTANCE` must be stripped in the setup file and never in a hook, because `config/instance.ts` captures it into a module-level const on first import.
Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`.
⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation.
### install.sh: the public installer
**`install.sh`** (repo root) is the public entry point: `curl -fsSL <raw url> | bash` installs Node/tmux/git/build tools if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. Since installer v2 (2026-09-20) it is **look, ask, work, done**: `preflight_detect` prints what is on the machine, every human step runs BEFORE the build (one consent for all missing packages, ONE sudo prompt kept warm by `sudo_session_start`, the AI CLI menu, the Tailscale login/operator/HTTPS-toggle preflight), then the clone/`npm install`/build/service/serve run unattended behind `run_step` spinners (output in `~/.codeman/install.log`, tail shown on failure), and `print_done_screen` ends on the URL with a terminal QR code (the `qrcode` package Codeman already ships). Design + verification record: `docs/installer-v2-plan.md`.
The network-access question is 3-way: **Tailscale** (loopback bind + `tailscale serve`, curl-verified end-to-end), **LAN** (0.0.0.0 + password prompt), or **local-only**; it preserves the existing binding on re-runs via `read_existing_binding()`, which also reads back `CODEMAN_BASE_URL`/`CODEMAN_PORT` from the unit, and the flag/env PRESET paths keep an existing password too (`${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}`: `--lan --service` on a password-protected unit used to rewrite it open with the unauthenticated ack, found in review 2026-09-21); `--password`/`--port` flip `RECONFIGURE` so they reach the unit instead of taking the quiet update path.
⚠️ The env a hand-started `codeman web` needs (host, password, ack, base URL, port) is composed in ONE place, `start_command_hint` for the done screen's Start line and `export_bind_env` for main()'s `exec` branch, so the two cannot disagree; `stop_background_helpers` runs right before that `exec`, because exec skips the EXIT trap and the sudo keepalive (keyed on `$$`, which becomes the server's pid) would otherwise refresh the sudo timestamp for the server's whole life. Ctrl+C in the HTTPS-toggle poll is trapped for the poll only and skips Tailscale for the run rather than killing the installer.
⚠️ **Tailscale is two halves on purpose**: `tailscale_prepare` (question phase: preflight, the opt-in rename, the serve SHAPE) and `tailscale_apply` (after the build: the one serve command). The shape is decided up front because it can change the service unit: when `:443` root already belongs to another app, the default is a **sub-path** (`tailscale serve --bg --set-path /codeman <port>` + `CODEMAN_BASE_URL=/codeman` in the unit; measured 2026-09-20: serve STRIPS the mount prefix before proxying, Codeman's `stripBasePath` tolerates unprefixed requests, and `--base-url` is what makes the emitted URLs carry it, verified live through the maintainer's tailnet incl. hashed assets and SSE), else a second port (8443+), replace, or skip. `detect_tailscale_serve_url` recognises all three shapes (`ts_serve_find_port_mapping`).
⚠️ **Rename is opt-in and defaults to NO everywhere** (owner decision 2026-09-20: the tailnet name is the machine's SSH identity); `--name`/`install.sh name` do it, `--yes` and non-interactive never do. Serve config is keyed by the DNS name it was written under, so `tailscale_rename_node` takes OUR mapping down first (`tailscale_remove_our_mapping`, `TS_MAPPING_REMOVED_BY_RENAME`) and it is re-added under the new name; the previous name is recorded in `~/.codeman/tailscale-rename` so uninstall can offer it back. Tailscale state is detected dynamically from `tailscale serve status --json` (no marker files); the installer must NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Tailscale Service (a Service needs a TAGGED node + admin approval, so it is a docs hint only); `test/install-sh-invariants.test.ts` pins all of those plus the rename-before-shape order and the flag/header parity.
A foreign `/Library/LaunchDaemons/com.codeman.web.plist` (the Mac mini's headless setup) is now LEFT ALONE rather than replaced by a LaunchAgent. Subcommands: `update`, `uninstall`, `tailscale` (retrofit), `name [<n>]`, `status` (the done screen again), `cloudflared` (the tunnel client, no longer a question in the main flow); flags `--tailscale|--lan|--local`, `--name|--no-rename`, `--service|--run|--no-start`, `--yes`, `--password`, `--port` pipe through `bash -s --` and set the same variables as their env twins (`CODEMAN_NONINTERACTIVE=1` approves system changes for automation and never installs Tailscale, renames or starts a service; `CODEMAN_TAILSCALE=1` presets the Tailscale choice).
Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers in the file), not a hand-written list: detection, the install menu and the closing reminder all read it, which is what stops the class of bug upstream `b6d0f1fa` fixed by hand (a user with only omp installed being told no AI CLI was found).
⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see.
⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all.
+314
View File
@@ -0,0 +1,314 @@
# CLI management Settings UI + write API — plan
> Tracked separately from `DEPLOYMENT_PLAN.md` (PR B2, merged) and `docs/copilot-integration-plan.md`
> (parked). This is "PR C" from the original #343 review: *"settings UI + write endpoints +
> auto-install, once we've settled the trust model... I want to make that call on its own, not
> inside a 100-file diff."*
>
> **Phase 0 is CLOSED as of 2026-09-21** — all three original pieces are IN SCOPE (expanded from
> this plan's first draft, which recommended #2/#3 as separate/out-of-scope; the user chose full
> scope instead, with the risk called out explicitly for #3 before confirming). See "Decisions"
> below for the full record.
## Status as of 2026-09-22
**Phases 1–6 are ALL IMPLEMENTED** (commits `da07b38c` "add cliManagementEnabled flag and GET
/api/clis" and `db4557d9` "Phases 3-6 - write API + custom entries + Settings UI", both on this
branch, `feat/cli-management`). Confirmed present in the tree: `cliManagementEnabled` in
`SettingsUpdateSchema`; `GET /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`,
`POST /api/clis`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id` in
`src/web/routes/cli-registry-routes.ts`; the `shell`/`claude` `UNDISABLEABLE_IDS` backend guard;
`isAdmin(req)` gating on both the list and write routes; `appendAdminAudit` wired into the install
route; tmp+rename+`0o600` writes in `registry-writer.ts`; the full Settings UI (row list, toggle,
Install button, custom-entry create/edit/delete form) in `settings-ui.js` + `index.html`.
`test/routes/cli-registry-routes.test.ts` (425 lines) and `test/cli-registry-no-id-branching.test.ts`
cover it. This status section, plus the fix and gap below, is the one piece of that work done in
a *different* session from the one that wrote Phases 1–6 — reviewed by reading the diff and
verifying each claim against the actual routes/tests, not by re-implementing anything.
### Gotcha found and fixed (commit `0c77dd0a`)
**Toggling a CLI off in Settings had no effect anywhere except the Settings row itself.**
`window.__codemanCliAvailable` — the flag `isCliAvailable()` reads client-side to gate the
welcome-screen buttons, the Run-menu dropdown and the mobile overview — is injected **once**, at
initial page render (`server.ts`), built purely from each CLI's own installed-on-PATH resolver
(`isClaudeAvailable()` etc.), with **no reference to the registry's `enabled` flag at all**. So
disabling a CLI here updated its own row and nothing else — every launch surface kept offering it,
both live and after a full page reload, since even a *fresh* render never consulted the registry.
Root-caused and reported by the user testing the live feature ("toggle those off, they still
appear in that menu and on the front main screen").
Fixed two places:
- `server.ts`: after building `available`, intersect the nine real `SessionMode` ids against
`enabledClis()`. `git`/`cloudflared` (utility binaries, not CLI registry entries) and
`deepseekBinary` (a secondary installed-only flag for the "add a profile" affordance) are
deliberately left alone — they were never registry-gated to begin with.
- `settings-ui.js`: `toggleCliEnabled()` now patches `window.__codemanCliAvailable` in place and
refreshes the welcome screen, the mobile overview and an already-open Run menu, mirroring the
existing `installDeepSeekProfile()` pattern for the same "injected once, needs an explicit
patch" reason — the server-side fix alone still left every surface stale until the next reload.
New test in `test/render-index-html.test.ts`: an installed-but-disabled CLI (codex, forced via
`clis.json` + `reloadCliRegistry()`) reads as unavailable, while an installed-and-enabled one
(claude) is unaffected by the override.
**Verified on the Debian devbox** (`codeman-devbox`, real tmux — this sandbox has none and
`WebServer`'s constructor hard-requires it): typecheck clean, the new test passes (17/17 in
`render-index-html.test.ts`), the CLI-registry suites pass (86/86), and the **full CI gate is
green — 415 test files, 7855 tests, 0 failures**.
### Launch-surface registry integration — completed
The welcome screen, desktop Run menu and mobile Run picker now use the same injected CLI catalog.
Every enabled registry entry is rendered; unavailable binaries remain hidden as before. Settings
updates the catalog and availability flags in place after enable/disable, create, edit or delete,
so the launch surfaces update without a page reload. A custom entry uses the generic quick-start
path, while stock entries retain their existing per-CLI launch settings.
Not otherwise re-verified line-by-line against every Phase 1–6 checklist item below (e.g. the
exact wording of toasts, the "same PR" sequencing notes) — the checklists are left as originally
written; treat the **Status** section above as authoritative for what exists.
---
## Background
`src/config/cli-registry/registry.ts` is READ-ONLY today, and says so in its own header comment:
> "⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file... there is no
> settings UI and no write API yet... A `seededStockIds` ratchet belongs with the write API that
> needs it."
Confirmed on `master` (2026-09-21): no `/api/clis` route exists at all (read or write);
`~/.codeman/clis.json` is hand-edit-only; `resolveInstallCommandForPlatform()` is documented
"Display text only — never executed" — nothing runs an install command server-side today. The
original #343 review flagged the opposite (`spawn(command, {shell: true})`, `env.allowedPrefixes`
contributed from a write) as needing its own trust-model decision; that decision was never made
after the split, just dropped. This plan makes it.
**Closest existing precedent, and the template this plan follows for the read/write API**:
`src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` (#393/#430/#459) — a small
per-item JSON store, Settings-UI-driven, admin-gated in multi-user mode, tmp+rename+0600 writes.
**Precedent for the new master feature flag (Phase 1)**: `customModelEndpointsEnabled` —
`z.boolean().optional()` in `SettingsUpdateSchema` (`schemas.ts:1319`), a checkbox read/written by
id in `openAppSettings()`/`saveAppSettings()` (`settings-ui.js:401`/`:2120`). SYNCED, not
per-device (present in the schema, absent from `displayKeys`), default OFF.
**Spec refs for the whole plan:**
- `src/config/cli-registry/registry.ts` — the read path; `resolveRegistry()`'s merge semantics
(`deepMerge`, `UNMERGEABLE_KEYS`) apply unchanged to whatever this plan writes
- `docs/cli-registry.md` — registry shape, "The override file", "Arg-template safety" (the four
layers Phase 5's custom-entry validation must not weaken), "Adding a CLI" (the 5-step recipe a
custom entry does NOT get to skip just because it arrives via UI instead of a stock.ts edit)
- `src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` — read/write API template
- `docs/multi-user-plan.md`, `docs/security-architecture.md` — admin-gating conventions
- `CLAUDE.md` §Multi-user mode, §"Settings surface", §"Per-device vs synced settings"
---
## Decisions (Phase 0, closed 2026-09-21)
1. **Enable/disable a stock CLI's `enabled` flag** — IN SCOPE. Plus a **master feature flag**
(`cliManagementEnabled`, synced, default OFF) gating the whole Settings UI section's visibility,
matching this codebase's standing convention for new admin-facing surfaces.
2. **Auto-install** (stock CLIs' already-shipped, already-vetted install commands) — IN SCOPE,
same PR.
3. **Custom CLI entries via the UI** — IN SCOPE, **typed-argv only**: a custom entry goes through
the exact same schema/argv-safety path stock entries do (named token patterns, no raw shell-text
field). Its install command stays **display-only text**, same as every stock entry today — Phase
4's auto-install NEVER executes a custom entry's install command, only a stock one's. This is
the one place scope was deliberately narrowed relative to what was agreed in principle, because
`docs/cli-registry.md`'s arg-template-safety section exists specifically to keep config free of
shell text, and a free-text install command for a user-defined entry would reopen exactly that.
4. **`shell`/`claude` un-disableable** — enforced at the **backend**, not just the UI (a
frontend-only guard is bypassable with curl).
5. **Non-admin visibility in multi-user mode** — the CLI-management Settings section is **hidden
entirely** for a non-admin, not shown-empty.
6. **`seededStockIds` ratchet** — not needed. `deepMerge()` only overrides a key the file actually
sets, so a CLI absent from `clis.json.clis` always falls through to its stock `enabled` value
with no special-casing. (Carried over from the first draft, not re-litigated.)
---
## Phase 1 — Master feature flag: `cliManagementEnabled`
**Status:** DONE (commit `da07b38c`) — verified present in `SettingsUpdateSchema`, `index.html`,
`openAppSettings()`/`saveAppSettings()`.
**Spec refs:**
- `schemas.ts:1319` (`customModelEndpointsEnabled`) — the exact pattern to mirror: `z.boolean().optional()`
in `SettingsUpdateSchema`
- `settings-ui.js:401`/`:2120` — checkbox read/write by id in `openAppSettings()`/`saveAppSettings()`
- `CLAUDE.md` §"Adding Features" → "App setting" — decide per-device vs synced FIRST (this one is
synced: a feature toggle, not a display preference) and add to `displayKeys` NEVER for a synced
setting
**Checklist:**
- [x] Add `cliManagementEnabled: z.boolean().optional()` to `SettingsUpdateSchema`
- [x] Add the checkbox to `index.html`'s `#settings-clis` section, above where Phase 6's per-CLI
list will render — reads/writes via `openAppSettings()`/`saveAppSettings()` by id, same as
`customModelEndpointsEnabled`
- [x] `readCliManagementEnabled()` helper (mirrors `readCustomModelEndpointsEnabled()` in
`custom-model-routes.ts:609`) for the route file(s) in Phases 2-5 to gate on
- [x] When OFF: `GET /api/clis` still exists but the Settings UI section stays hidden
(`applyCliManagementVisibility()`); the write endpoints reject (see Phase 3)
**Verify:** `npm run typecheck` passes; a unit test confirms `SettingsUpdateSchema` accepts/rejects
the field correctly; toggling it in a fresh browser profile shows/hides the Settings section with
no server restart.
---
## Phase 2 — Read endpoint: `GET /api/clis`
**Status:** DONE (commit `da07b38c`) — verified present in `src/web/routes/cli-registry-routes.ts`.
**Spec refs:**
- `src/web/routes/custom-model-routes.ts:730` (`GET /api/model-endpoints`) — multi-user read
gating: empty list for a non-admin, never a 403
- `src/config/cli-registry/registry.ts` — `listClis()` (every entry, including disabled stock
ones — this is an admin/settings surface, unlike `enabledClis()`)
- `window.__codemanCliAvailable`'s resolvers (`isClaudeAvailable()` etc.) — candidate `installed`
source; confirm whether to reuse directly or the response needs its own probe (Open Question 4,
carried from the first draft — still genuinely open, decide during this phase not before)
**Checklist:**
- [x] New route file `cli-registry-routes.ts`
- [x] Response excludes `launch`/`env`/`capabilities`/`overlays`/`discovery`
- [x] `isMultiUserMode() && !isAdmin(req)` → `[]`
- [x] Unit tests in `test/routes/cli-registry-routes.test.ts` (admin/non-admin/single-user,
disabled stock CLI still present)
**Verify:** `npm test -- test/routes/cli-registry-routes.test.ts` passes; `curl localhost:3000/api/clis | jq`
shows every stock CLI including disabled ones.
---
## Phase 3 — Write endpoint: `PUT /api/clis/:id` (stock enable/disable)
**Status:** DONE (commit `db4557d9`) — `UNDISABLEABLE_IDS`, admin gate, tmp+rename+0600 all
confirmed present.
**Spec refs:**
- `src/web/routes/custom-model-routes.ts:753` + `src/custom-model-hosts.ts:91` — write-path
template: `adminOnly` gate, read-modify-write the WHOLE file, tmp+rename+0600
- `registry.ts:47` (`filePath()` = `dataPath(...)`) and `reloadCliRegistry()` — write to the same
resolved path, invalidate the cache on every successful write or the change is invisible until
restart
**Checklist:**
- [x] Body: `{ enabled: boolean }`. Zod schema in `schemas.ts`
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → shell/claude guard → stock-only guard
- [x] Rejects disabling `shell` or `claude` (`UNDISABLEABLE_IDS`)
- [x] Rejects a write for an id that isn't a stock CLI
- [x] Deep-merges `{ clis: { [id]: { enabled } } }`, preserving other override keys
- [x] tmp+rename+0600 write, `reloadCliRegistry()` on success
- [x] Unit tests (`test/routes/cli-registry-routes.test.ts`)
**Verify:** `npm test` full gate green; `curl -X PUT localhost:3000/api/clis/grok -d '{"enabled":false}'`
then `GET /api/clis` shows the change with no restart; same against `shell`/`claude` returns an
error and changes nothing; `ls -la ~/.codeman/clis.json` shows mode 0600.
---
## Phase 4 — Auto-install: `POST /api/clis/:id/install` (stock CLIs only)
**Status:** DONE (commit `db4557d9`) — route present, `appendAdminAudit` wired in.
**Spec refs:**
- `registry.ts:231` (`resolveInstallCommandForPlatform`) — currently "Display text only — never
executed"; this phase is what changes that, for stock entries only, with Decision 2's sign-off
- Original #343 review's exact concern re: `env.allowedPrefixes` contributed from a write — stays
out of scope; this phase only ever runs a command, never touches the env allowlist
**Checklist:**
- [x] Separate endpoint from Phase 3's toggle
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → stock-entry-only guard
- [x] `resolveInstallCommandForPlatform(entry)` for the target
- [x] Bounded execution (timeout, captured stdout/stderr)
- [x] Does NOT auto-enable on successful install
- [x] Audit-logged via `appendAdminAudit`
- [x] Unit tests
**Verify:** a real install triggered via the endpoint against a CLI not currently installed,
`GET /api/clis`'s `installed` field flips true with no restart; audit log entry present; attempting
install against a custom entry's id fails with a clear error; full CI gate green.
---
## Phase 5 — Custom CLI entries: create / update / delete via API
**Status:** DONE (commit `db4557d9`) — `POST /api/clis`, `PUT /api/clis/custom/:id`,
`DELETE /api/clis/:id` all present. Open Question 2 resolved: a **separate** endpoint
(`PUT /api/clis/custom/:id`), not Phase 3's `PUT /api/clis/:id` widened.
**Spec refs:**
- `docs/cli-registry.md` §"Arg-template safety" (all four layers), §"Adding a CLI" (the 5-step
recipe) — a custom entry created via this API must satisfy the SAME schema (`CliEntrySchema`)
every stock entry does; there is no relaxed path for UI-originated entries
- `registry.ts`'s `resolveRegistry()` — the custom-entry branch (`stock: false`, dropped with a
warning on validation failure, never falls back silently) already exists and is unchanged by
this phase; this phase only adds a way to WRITE what that branch reads
**Checklist:**
- [x] `POST /api/clis` (create), full `CliEntrySchema` validation
- [x] `PUT /api/clis/custom/:id` (update) — separate endpoint from Phase 3's stock toggle
- [x] `DELETE /api/clis/:id` refuses for any stock id
- [x] `id` collision check against existing stock ids
- [x] `discovery.install.command` on a custom entry stays DISPLAY-ONLY
- [x] Same tmp+rename+0600 write pattern, `reloadCliRegistry()` on every successful mutation
- [x] Unit tests
**Verify:** `npm test` full gate green; create a custom entry via curl, confirm it appears in
`GET /api/clis` — **confirm it appears in the Run menu is UNVERIFIED and currently FALSE, see
"Outstanding" above**; delete it, confirm it's gone and `clis.json` no longer references it.
---
## Phase 6 — Settings UI
**Status:** DONE (commit `db4557d9`) — `#cliListGroup`, row rendering, toggle, Install button,
custom-entry create/edit/delete form all present in `settings-ui.js`/`index.html`. Manual browser
verification per the phase's own "Verify" step (flag on/off, non-admin hidden, toggle stops the
Run menu offering a CLI, create/enable/launch a custom entry, delete it, shell/claude undisableable)
has **not** been re-run in this session — the toggle→Run-menu leg specifically was BROKEN until the
gotcha fix above, and the create→launch leg for a custom entry is the confirmed gap in
"Outstanding".
**Spec refs:**
- `index.html:2357` (`#settings-clis`) — the existing home; Phase 1's master toggle at the top,
then the per-CLI list, then (if `cliManagementEnabled`) a "custom CLI" creation form, all above
the existing Codex-only groups
- `CLAUDE.md` §"Settings surface" — App Settings scrolls, it does not tab-switch
- `admin-ui.js` — pattern for an admin-only-VISIBLE section (not just admin-only-writable),
needed here per Decision 5
**Checklist:**
- [x] Whole section hidden when `cliManagementEnabled` is OFF, and separately hidden for a
non-admin in multi-user mode (`_applyCliManagementAdminGate`)
- [x] Fetches `GET /api/clis` when the section becomes visible; renders one row per CLI
- [x] Stock rows: enabled toggle only; `shell`/`claude` rows show the toggle disabled/greyed
- [x] Custom rows: enabled toggle plus edit/delete affordances
- [x] "Add custom CLI" form (id/label/badge/binary/argv)
- [x] Toggle/edit/delete update the row in place
**Verify:** manual browser test per `CLAUDE.md`'s "Always Test Before Deploying" rule — **not yet
re-run end-to-end in this session**; do this before considering the feature ready to ship, and
expect the custom-entry-launch step to fail until the Outstanding gap above is closed.
---
## Remaining Open Questions
1. **Phase 2's `installed` source** — resolved: reuses `window.__codemanCliAvailable`'s existing
resolvers via `GET /api/clis`'s own probe (confirmed by reading the route).
2. **Phase 5's `PUT` endpoint shape** — resolved: a **separate** endpoint
(`PUT /api/clis/custom/:id`), not Phase 3's toggle route widened.
3. **Sequencing against the parked Copilot plan** — unchanged, still not blocking.
4. **NEW: custom-CLI Run-menu integration** — see "Outstanding" above. Not decided or started.
---
Implementation is underway (see Status above); this line is left for history rather than removed —
the plan was originally approved before Phases 1–6 landed.
+70 -5
View File
@@ -18,7 +18,17 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
## The override file
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read only on restart.
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read after a change made through CLI management (below).
## Managing CLIs from Settings
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
## The shape of an entry
@@ -36,7 +46,9 @@ interface CliEntry {
launch: CliLaunch; // the structured argv template
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
// — how this CLI's pane shows work, work it started in the background, and a turn
// that ended waiting for workers it will resume from
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -45,10 +57,61 @@ interface CliEntry {
### Regexes that come from config
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
so a pane waiting for its own background work never raises an alert a human cannot answer.
Group 1 is the label, and a CLI that declares no pattern reports no background work.
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
to comment on a page the agent published, so Claude's pattern refuses any footer that
carries it, and the idle alert goes out as usual.
Two CLIs declare such a row today, and they put it in different places. Claude writes its
chip on the last row of the screen, so it keeps the default one-row window and anchors on
the `·` its footer joins items with. Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
puts the row third from the bottom once the status line and the composer are counted, so its
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
against live panes rather than read out of a binary, which is the standard for adding a
third.
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
to wait for workers the CLI will resume from by itself. When background agents or an
ultracode workflow are still running at turn end, Claude closes the turn with
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
the words are still on screen after the workers report back and the follow-up turn ends.
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
tests only the first row that starts in column 0, which is the newest transcript row. Claude
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
agent from holding its own session busy.
That label is the one value in the registry that an AGENT can influence, because it comes off
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
non-blank rows, which should be the part of the screen the CLI draws rather than the agent,
and the pattern should anchor on chrome only that CLI can produce. Keep the window as small
as the layout allows, since every row it adds is another row the agent may be able to write.
The label is also ANSI-stripped and length-capped at the source, and every interpolation of
it into markup goes through `escapeHtml()`, since it ends up on a badge and in an approval
card.
The two shipped entries do not sit equally well behind that rule, and the difference decides
what a pattern is allowed to do. Claude's chip is the last row, so its one-row window holds
nothing the agent can write — not even the status line above it, whose command a session
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
shares its slot with the last row of the transcript whenever no terminal is running, so a
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
sure its row is one the agent cannot write.
### Three capabilities that must stay independent
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
@@ -102,6 +165,8 @@ It matches four shapes, not one: `mode === '<id>'`, `mode !== '<id>'`, `case '<i
The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `<Mode>Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong).
`test/frontend-cli-no-id-branching.test.ts` is the same guard for the two frontend files the CLI registry's Run-menu consolidation touches, `session-ui.js` and `mobile-overview.js` — deliberately not the rest of `src/web/public/`, whose per-CLI rules stay out of scope for now (see "Fields declared for later" below). Its allowlist keys on `<file>::<expression>` with no line number, since a single unrelated edit to a contended file would otherwise shift every subsequent line and make every entry go stale at once, and each entry additionally carries the exact number of approved call sites — a bare key would let a brand-new branch reusing an already-approved expression land unreviewed. Its comparison shape differs from the backend guard's in one respect: the left-hand side may be any identifier, not only one named `mode`, `id` or `agentType`, because the review of #458 found `const m = this._runMode; if (m === 'codex')` slipping past the named form while the scanned file already filters with `(m) => m !== 'shell'`.
## Two namespaces called `param`
`launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a **launch param**. The **legacy wire field** a param arrives as is a separate namespace, and `launch.legacyConfigAliases` is the only bridge between the two.
@@ -110,9 +175,9 @@ This matters because it is invisible when it is wrong. `capabilities.privilegedP
## Fields declared for later
`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
`accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. (`shortBadge` was on this list until the CLI management list in Settings started showing it.) They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches the gradient CSS paints, so re-measure before wiring one up. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, so re-measure before wiring one up. `accent` is the one exception: it was measured against styles.css on 2026-09-21 (method in the comment above `CLAUDE` in `stock.ts`), though nothing keeps it in step with the CSS either. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest.
+28
View File
@@ -183,6 +183,34 @@ launch, with the endpoint's `defaultModelId` marked but not auto-chosen —
the point of asking is letting one launch deliberately differ from the
saved default, not just confirming it.
The modal promotes exactly one row to the top of the list rather than
always showing raw discovery order, so the zero-wait choice is the one
under your thumb:
- **"Currently loaded"** — a model from this host's own list that
llama-swap reports `ready` right now, queried via
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
~800ms (`Promise.race`), on top of the route's own 5s server-side
timeout, so an endpoint that is asleep or firewalled cannot leave the
modal invisible for the full 5s after the Run menu has already closed.
- **"Last used"** — shown only when nothing is currently loaded: the model
actually launched last for this exact (harness, endpoint) pair, read
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
`runCustomModelEntry` entry point itself only dispatches between the
two) only once the model is actually applied, never on the mere click —
declining the context-window warning means this exact model cannot work
with this CLI at all, so promoting it next time would be actively wrong,
not just premature.
Neither tag reorders anything past that one promoted row. The "Default"
pill is a separate span, not a third value of the same slot: a promoted
row that is also the endpoint's `defaultModelId` shows both tags (on a
single-purpose GPU box that is the common case, and an exclusive slot
silently dropped the Default marking for exactly that row), and a row
with neither promotion nor default shows no tag at all.
**How the launch itself applies the endpoint depends on the harness.** For
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
+3 -1
View File
@@ -53,7 +53,9 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
surfaces that same line as the install error. `npm install -g pnpm` (or
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
alongside `dsh`.
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
button works there too.
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
margin the most used community TUI, it is MIT, and it implements the status
+4
View File
@@ -81,6 +81,10 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
+5 -1
View File
@@ -6,6 +6,10 @@ For the Compose configuration, environment settings, storage migration, and macv
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
@@ -67,7 +71,7 @@ If that directory was created by an earlier root-running image, change its owner
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. For a major update, or a base-image change `Start-Codeman.sh` does not fully pick up, `docker/Update-Codeman.sh` rebuilds with no layer cache and clears the two build-artefact volumes before handing off to it (see "Major updates" in `docker/README.md`).
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
+14 -8
View File
@@ -10,15 +10,18 @@ this file covers only what the container changes.
## The short version
| Change in the release | Applied by |
| -------------------------------- | ------------------------------------------------ |
| Application code | The in-app updater |
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
| Change in the release | Applied by |
| ----------------------------------------- | ------------------------------------------------------------------------------- |
| Application code | The in-app updater |
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
| A major update, or a Node base-image bump | `docker/Update-Codeman.sh` on the host (no-cache rebuild + fresh build volumes) |
The in-app updater detects all three of the bottom rows itself and refuses with a
The in-app updater detects the three middle rows itself and refuses with a
message naming what changed, so you never have to work out which case you are in.
`Update-Codeman.sh` is the heavier option for when `Start-Codeman.sh` is not
enough: see "Major updates" in `docker/README.md`.
## Why the container needs its own path
@@ -224,7 +227,10 @@ the host and the in-app path works from then on.
**Resetting the build artefacts** — `docker compose down -v`, then
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
image.
image. `docker/Update-Codeman.sh` scripts the same reset by default for the two
build-artefact volumes (`codeman-node-modules`, `codeman-dist`) only, plus an
unconditional `--no-cache` rebuild, which a plain `Start-Codeman.sh` run does not
force on its own. See "Major updates" in `docker/README.md`.
## Disabling it
+36
View File
@@ -338,6 +338,42 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
codeman status | list | attach <path> codeman doctor
```
### Opening a session from your own page
To send someone from your page to one session, link to the dashboard with the
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
dashboard selects that tab when it loads. It also removes the fragment from its
own URL, so a later link to the same session still counts as a change.
Keep reusing one named window to make later links fast:
```js
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
```
When that window already shows the dashboard, only the fragment differs. The
browser therefore keeps the page loaded, and the dashboard switches tabs without
reloading it. A session the window has shown before appears at once. A session
your page has only just created may not be listed yet, so the dashboard waits
for its `session:created` event and selects it then. That wait lasts at most 30
seconds: a link whose session never appears (a closed session, a typo, or in
multi-user mode another user's session) is dropped with a "Session not found"
notice. Clicking another tab, going Home or opening a web tab also ends the
wait, so a session that turns up later never takes the screen from the person.
When your page holds the window reference (`const win = window.open(...)`),
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
without a reload, but adds no history entry, so Back in the dashboard window
does not turn into a silent no-op.
Following a link does not count as someone looking at the session, so it
leaves the session's idle alert in place. The alert clears when the person
clicks the tab or types into the session. A link to a session that is popped
out into its own window asks that window to come forward, as clicking its tab does.
A link to `/session/<id>` opens a page showing that session alone, and that
page loads from scratch for every link.
## Seam 4: Hooks
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
+380
View File
@@ -0,0 +1,380 @@
# Installer v2: three questions, then a URL you can open on your phone (Plan)
Status: **Phase 1 IMPLEMENTED (2026-09-20)**, phases 2 and 3 open. It builds on
`docs/tailscale-installer-plan.md` (implemented 2026-08-04), which made Tailscale a
guided option; this round makes it the thing the install ENDS on, and makes the whole
installer shorter to sit through. Owner decisions taken before implementation: rename
is opt-in and **defaults to no everywhere** (the machine name is used for other things);
the URL keeps the node name unless asked; `codeman-<hostname>` is the suggested name;
sub-path is the default for an occupied `:443`.
Verification record for phase 1 (all on the maintainer's box, 2026-09-20):
- `test/install-sh-invariants.test.ts` (28 tests, incl. the new Tailscale safety pins)
and the detection-parity test pass; `bash -n` passes.
- Every new decision function driven with stubbed tailscale state under **bash 5.2 and
bash 3.2** (the `bash:3.2` container CI uses): flags, the launch default, the serve
shape for free / ours / occupied `:443` (all four answers plus the non-interactive
default), the three serve commands, the rename question (Enter keeps the name; `--yes`
and non-interactive never rename; `codeman-*` nodes are skipped; `--name` is
sanitized), `run_step` success/failure/stdin, the unit round-trip of
`CODEMAN_BASE_URL`/`CODEMAN_PORT`/an escaped password, and the done screen.
- A full non-interactive install into a sandboxed `HOME` with `CODEMAN_TAILSCALE=1`:
preflight summary, kept the existing prod mapping (no serve mutation), clone 2 s,
`npm install` 18 s, build 23 s, symlink, done screen; `install.sh status` on a pty
renders the QR code. Nothing on the real system changed.
- **Sub-path mode end to end over the real tailnet**: an isolated Codeman
(`CODEMAN_INSTANCE`, port 3999, `--base-url /codeman`) behind
`tailscale serve --https=8445 --set-path /codeman 3999` answered `/codeman/api/status`,
`/codeman/` (with `<base href="/codeman/">` and `__CODEMAN_BASE__="/codeman"`), the
hashed CSS/JS, `/codeman` without a slash, and the SSE stream; mapping and server
removed afterwards. **Correction to section 2**: serve STRIPS the mount prefix
before proxying (a direct `/codeman/api/status` on the server is 404 while the same
path through serve is 200). That is fine because Codeman's ingress tolerates
unprefixed requests; `--base-url` is needed for the URLs Codeman EMITS, not for
what it receives.
- Not yet exercised on a fresh machine (unchanged from the previous plan): Tailscale
absent / logged out / HTTPS toggle off, the rename against a real node (the
off-rename-re-add order is implemented but only unit-driven), macOS, uninstall. The
Mac mini and a throwaway VM are the venues; see section 8.
- **Review fixes (2026-09-21)**, from the two reviews on PR #460 (DeepSeek Harness, then
Claude): the done screen's Start line is composed from every non-default value
(`start_command_hint`, shared with the exec branch as `export_bind_env`), so "do not
start" under a sub-path or a custom port no longer prints a bare `codeman web`; the
`--lan`/`--tailscale`/env preset paths keep an existing password instead of rewriting
the unit open; `--password`/`--port` flip `RECONFIGURE` so they reach the unit;
`install.sh name` re-syncs the unit's base URL after a rename; the sudo keepalive is
ended before the `exec` into the foreground server; Ctrl+C in the HTTPS-toggle poll
skips Tailscale instead of killing the run; `uninstall` asks before removing a
LaunchDaemon it never wrote; a foreign LaunchDaemon gets a restart hint and the done
screen stops claiming the new build is running; the preflight summary reads the
Tailscale state without node; the LAN security notice uses the configured port; a
bare re-run ends on the done screen; a build failure after a rename names the
`install.sh tailscale` recovery; `TS_JOINED_HERE` is gone.
Goal, in one sentence: a user runs the one-liner, answers at most three questions, walks
away during the build, and comes back to `https://<name>.<tailnet>.ts.net` printed with a
QR code, already answering, on every device in their tailnet. That is exactly the
maintainer's own production setup (`tnode.tailf80371.ts.net` fronting `127.0.0.1:3000`),
and the installer should produce it without the user knowing what `tailscale serve` is.
## 1. Where the installer is today
Facts from reading `install.sh` (2886 lines, 19 `prompt_yes_no` sites) and the live
Tailscale state on the maintainer's box (tailscale 1.102.2, user-owned node, MagicDNS +
HTTPS certs on, serve mapping `443 -> https+insecure://localhost:3000`).
**The order is backwards for a human.** The flow is: detect -> ask about git -> ask about
node -> ask about tmux -> ask about build tools -> AI CLI menu -> ask about cloudflared ->
clone -> `npm install` -> build (minutes) -> **then** the network-access question -> the
Tailscale sub-steps (install? login URL, sudo for operator, admin-console toggle loop) ->
the launch menu (no default; a bare Enter re-prompts) -> tunnel-service question. A fresh
Ubuntu server taking the Tailscale route answers roughly ten prompts plus two to four sudo
password prompts, split around a multi-minute build. The user cannot walk away at any
point, and the question that matters most (how do I reach it) comes last.
**The Tailscale flow works but was never exercised on a fresh machine.** The previous
plan's manual matrix still lists items 1-4, 7 and 10-12 (Tailscale absent, logged out,
HTTPS toggle off, port 443 occupied, macOS, uninstall, phone PWA) as untested. The
maintainer's own verification was the idempotent "kept as-is" path.
**The URL is the machine's name, full stop.** `setup_tailscale_serve` derives it from
`.Self.DNSName`, and nothing lets the user influence it. A second Codeman on the same
tailnet is `macminis-mac-mini.tailf80371.ts.net`, which tells you nothing about Codeman.
**Port 443 taken means give up or clobber.** If another app already owns the root of
`:443`, the only offer is "replace it?" (default no), and declining falls back to
local-only. Codeman already supports running under a sub-path (`--base-url`), and
Tailscale serve supports mounting a path (`--set-path`), so there is a third answer nobody
is offered.
**The result is invisible afterwards.** Once the terminal scrolls away, nothing in the app
or the CLI tells the user their Tailscale URL again. `codeman doctor` does not probe
Tailscale; App Settings -> Remote access shows only the Cloudflare tunnel.
**Two service writers exist.** `install.sh` carries its own plist/unit generator (~180
lines) next to `codeman service install` (`src/service-installer.ts`). They agree on the
job name by design, but the bash copy is the one that writes `CODEMAN_PASSWORD` into the
unit, so they cannot simply be merged. Left as-is in this plan (see section 9).
## 2. What Tailscale makes possible for the name (researched 2026-09-20)
| Option | Resulting URL | What it needs | Side effects | Verdict |
| ------ | ------------- | ------------- | ------------ | ------- |
| **A. Node name** (today) | `https://tnode.tailf80371.ts.net` | `tailscale serve --bg 3000` | none | **Default.** Zero admin-console work, matches the maintainer's prod. |
| **B. Rename the node** | `https://codeman-tnode.tailf80371.ts.net` | `tailscale set --hostname codeman-<host>` (operator or root) | Renames the machine tailnet-wide: ssh targets, other serve URLs, the admin console entry. Tailscale de-dups a clash as `-1`. The cert follows the new name. | **Opt-in, default NO everywhere** (owner decision 2026-09-20: the machine is used for other things, so a bare Enter never renames it). The proposal was YES when the installer itself had just joined the tailnet; rejected. |
| **C. Tailscale Service** | `https://codeman.tailf80371.ts.net` | tailscale >= 1.86 on the host; the host must have a **tag-based identity** ("You cannot use a device authenticated with a user account as a Service host"); the service is defined in the admin console first; the host is then approved there (or via `autoApprovers.services`). Public beta since 2025-10-28, all plans. | Re-authenticating a personal machine as a tagged node changes its identity (SSH ACLs, user attribution). Known daemon quirk: approval is not picked up until `serve clear` + re-advertise (tailscale/tailscale#18821). | **Detect and hint only** in this round. The maintainer's own node has `Self.Tags: null`, so it could not host one without re-tagging. Worth a real flow once someone with a tagged fleet asks. |
| **D. Sub-path** | `https://tnode.tailf80371.ts.net/codeman` | `tailscale serve --bg --set-path /codeman 3000` plus `--base-url /codeman` on the server | Codeman runs under a prefix. Hooks are unaffected (they hit the raw port with no prefix, which `rewriteUrl` already tolerates). Serve forwards the prefix unchanged, which is exactly the shape `--base-url` was built for. | **The answer when `:443` root is already taken.** Replaces today's replace-or-nothing prompt. |
| **E. Second port** | `https://tnode.tailf80371.ts.net:8443` | `tailscale serve --bg --https=8443 3000` | Port in the URL; the beta-preview recipe already uses this. | Fallback when the user rejects D. |
| Funnel (public internet) | `https://tnode.tailf80371.ts.net` from anywhere | `tailscale funnel` | Public exposure; different risk class. | **Out of scope**, as before. Docs only, with the password warning. |
Sources: Tailscale Services docs (`tailscale.com/docs/features/tailscale-services`), the
Services beta announcement (`tailscale.com/blog/services-beta`), machine names
(`tailscale.com/kb/1098/machine-names`), the serve CLI reference
(`tailscale.com/docs/reference/tailscale-cli/serve`), the macOS variants page
(`tailscale.com/docs/concepts/macos-variants`), and `tailscale serve --help` on 1.102.2
(which lists `--service`, `--set-path`, `--yes`, `advertise`, `get-config`/`set-config`).
**Trap for option B (verify on the Mac mini before shipping):** the serve config is keyed
by `host:port` using the DNS name at configuration time (`"Web": {"tnode.tailf80371.ts.net:443": ...}`
in `serve status --json`). Renaming a node after serve is configured most likely orphans that
entry: the handler lookup uses the current name and never matches the old key, and the only
tool that removes a stale key is `serve reset`, which this installer must never run. So the
order is **rename first, then configure serve** on a fresh install, and on a retrofit
(`install.sh name`) **turn our mapping off, rename, wait for `.Self.DNSName` to change,
re-add**.
## 3. Target UX
### 3.1 Three questions, then walk away
```
Codeman installer
Found: git, Node 22.14, tmux 3.4, build tools Missing: nothing
AI CLIs: Claude Code (~/.local/bin/claude)
Tailscale: connected as tnode (tailf80371.ts.net)
Existing: none
1/3 How should the dashboard be reachable?
1) Tailscale https://tnode.tailf80371.ts.net (recommended, already connected)
2) Any device on your network (0.0.0.0, password required)
3) This machine only (127.0.0.1)
Choose [1/2/3] (default 1):
2/3 Name this machine "codeman-tnode" on your tailnet? [y/N]
(only shown for option 1; default no, always)
3/3 Run Codeman as a background service that starts on boot? [Y/n]
Installing… this takes a few minutes. You can leave this running.
✓ dependencies ✓ clone ✓ build (2m 41s) ✓ service ✓ tailscale serve
```
Rules that make this work:
- **Every step that needs a human runs BEFORE the build.** The dependency consent, the
AI CLI menu, the Tailscale install consent, the `tailscale up` login URL, the operator
grant, and the tailnet HTTPS toggle all move into the question phase. The build, the
service, `tailscale serve` and the verification are unattended.
- **One consent for all missing system packages.** "Install git, Node 22 and build tools
now? [Y/n]" replaces four separate prompts. Each package still runs its own
distro-specific installer.
- **One sudo prompt.** When anything needs root (packages, the Tailscale installer,
`tailscale up`, the operator grant), the installer says so once, runs `sudo -v`, and keeps
the timestamp alive in a background loop until it exits. macOS needs no sudo for the
Tailscale GUI-app CLI and the pattern still holds for Homebrew packages.
- **Service is the default.** Enter on the last question installs the service; "run in
this terminal" and "don't start" stay reachable by answering, and by flag.
- **The cloudflared question is gone from the main flow.** It is optional, defaults to
no, and has an in-app toggle (App Settings -> Remote access). The done screen mentions it
only when `cloudflared` is already installed. The Linux tunnel-service prompt goes with it.
- **The HTTPS-certificates toggle no longer asks "re-check now?"** The installer prints the
admin URL, opens it in a browser when one is available (`xdg-open` / `open`, never on a
headless box), and polls `tailscale status --json` every 5 s for up to 5 minutes. Ctrl+C or
the timeout falls back exactly as today.
- **Progress, not silence.** `npm install` and `npm run build` run behind one line each
with elapsed time; their output goes to `~/.codeman/install.log` and is printed only on
failure, with the exact retry command.
### 3.2 The done screen
One block, the URL first, a QR code the phone can scan, and nothing the user does not need
right now.
```
✓ Codeman 1.31.0 is running
Your tailnet: https://codeman-tnode.tailf80371.ts.net (HTTPS, any of your devices)
This machine: http://localhost:3000
▄▄▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄▄▄
█ ▄▄▄ █ ▄▄▀ ▄ █ ▄▄▄ █ scan with your phone
█ ███ █ ███▀▀ █ ███ █
█▄▄▄▄▄█ █ ▄ █ █▄▄▄▄▄█
Manage systemctl --user restart codeman-web · journalctl --user -u codeman-web -f
Update re-run the install line, or App Settings → System → Updates
Docs https://github.com/Ark0N/Codeman/wiki
Security: Codeman binds 127.0.0.1. Tailscale authenticates every device before a
packet reaches it. Details: docs/security-architecture.md
```
The QR comes from the `qrcode` package Codeman already depends on
(`node -e "require('qrcode').toString(url, {type:'terminal', small:true}, …)"` from
`$INSTALL_DIR`, verified locally: 17 rows by 45 columns). Skipped when the terminal has no
color support or fewer than 50 columns. The QR encodes the plain URL, not an auth token:
the tailnet is the login.
### 3.3 Express mode and flags
Env vars stay (`CODEMAN_TAILSCALE=1`, `CODEMAN_HOST`, `CODEMAN_PASSWORD`,
`CODEMAN_NONINTERACTIVE=1`, `CODEMAN_PORT`). Flags are added because they are
discoverable from the one-liner and pipe through `bash -s --`:
```bash
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --name codeman-build --yes
```
| Flag | Meaning |
| ---- | ------- |
| `--tailscale` / `--lan` / `--local` | Answer 1/3 (same semantics as `CODEMAN_TAILSCALE=1`, `CODEMAN_HOST=0.0.0.0`, `CODEMAN_HOST=127.0.0.1`) |
| `--name <n>` / `--no-rename` | Answer 2/3: rename the node to `<n>`, or never ask |
| `--service` / `--run` / `--no-start` | Answer 3/3 |
| `--yes` | Accept every default, still prompt for a login URL (a human must open it) |
| `--password <p>` | Same as `CODEMAN_PASSWORD` |
| `--port <n>` | Same as `CODEMAN_PORT`; the serve target follows it |
`--yes` differs from `CODEMAN_NONINTERACTIVE=1`: it is the interactive user saying "I trust
the defaults", so it may install software and may wait on a login URL. Non-interactive stays
the CI contract and never installs Tailscale.
## 4. The Tailscale flow, v2
The state machine from the previous plan stays; these are the changes.
1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install
(Linux: official script; macOS: brew cask, else download link and wait). Logged in? ->
`tailscale up` with the URL printed prominently and a 5-minute poll. Operator (Linux):
grant once under the single sudo session. HTTPS certs: poll instead of ask (Ctrl+C
during the poll skips Tailscale for this run rather than ending the installer). The
rename default does not depend on whether this run performed the login (decided NO
everywhere), so nothing records it.
2. **Name** (`tailscale_choose_name`, question 2/3): shown only on the Tailscale route.
Default `codeman-<oshostname>` sanitized to `[a-z0-9-]`, max 63. Applied with
`ts_cmd_serve set --hostname`, then poll `.Self.DNSName` until it carries the new name
(up to 60 s). Order matters: this runs before any serve mutation (section 2 trap).
Declining keeps the node name. On a re-run against a node already named `codeman-*`,
the question is skipped.
3. **Serve, after the service is up** (`setup_tailscale_serve`): unchanged idempotent
"kept as-is" path first. When `:443` root belongs to another target, the new prompt is:
```
tailscale serve already sends https://tnode.tailf80371.ts.net to port 8080.
1) Add Codeman under a path: https://tnode.tailf80371.ts.net/codeman (default)
2) Use another port: https://tnode.tailf80371.ts.net:8443
3) Replace the existing mapping with Codeman
4) Skip Tailscale for now
```
Option 1 writes `--base-url /codeman` into the service unit (it is a `WebLaunchOptions`
field already, and `buildWebArgs` carries it) and runs
`tailscale serve --bg --set-path /codeman <port>`. Option 2 runs `--https=8443`.
`detect_tailscale_serve_url` learns to recognize all three shapes (root, path, port) so
uninstall, the security notice and the re-run default keep working.
4. **Warm the certificate.** Right after serve is configured, fire one background
`curl -sk https://<url>/api/status` so Let's Encrypt issuance overlaps the rest of the
install instead of adding 30 s to the verify step.
5. **Verify** as today (200 or 401 on `/api/status`), with the path-aware URL.
6. **Services hint** (option C): when `.Self.Tags` is non-empty and `serve --help`
lists `--service`, the done screen adds one line: "This is a tagged node, so it can also
host `https://codeman.<tailnet>.ts.net` as a Tailscale Service: see Remote Access in the
wiki." No flow, no prompt.
7. **macOS**: the App Store and Standalone variants cannot run before login, so a
LaunchAgent plus serve only comes back after someone logs in. The done screen says so on
macOS. The Mac mini (`arbbot`, headless, system LaunchDaemon) is the reference for the
"headless Mac" caveat, and `install.sh` must keep refusing to replace a LaunchDaemon it
did not write (today it removes one; that is a bug for the Mac mini and is fixed here:
detect `UserName` in the daemon plist and leave it alone with a message).
8. **Uninstall** additionally offers to restore the original node name when this installer
renamed it (the original is recorded in `~/.codeman/install.json`, the one marker file
this feature adds, because tailscaled does not remember previous names).
9. **Subcommands**: `install.sh tailscale` (unchanged purpose, now runs the v2 flow),
`install.sh name [<n>]` (rename with the off/rename/re-add dance), `install.sh status`
(prints the done screen again, URL and QR included, for the "what was my URL" moment).
## 5. In-app: the URL stays discoverable
Small, read-only, and the first server-side code this feature has ever needed.
- **`GET /api/system/remote-access`** returns
`{ tailscale: { installed, connected, dnsName, url, mode: 'root'|'path'|'port'|null } }`
by running `tailscale status --json` and `tailscale serve status --json` through
`execFile` with the existing exec timeout, cached 30 s, resolved through the same
`get_tailscale_path` search as the installer (PATH, then the macOS app bundle), and a
no-op under `VITEST` like every other IO probe. Never mutates serve config.
- **App Settings -> Remote access** gains a **Tailscale** row above the Cloudflare toggle:
the URL as a copy chip, a QR button reusing `showTunnelQR`'s modal, and when nothing is
configured a one-line hint with `bash ~/.codeman/app/install.sh tailscale`. The welcome
screen's "open on your phone" affordance shows the same QR.
- **`codeman doctor`** grows a `tailscale` entry under `other` in
`config/dependency-registry.ts`: installed, connected, serving Codeman (URL). Pure
engine, injectable probe host, like the existing rows.
- No new SSE event, no settings key, no state.json change.
## 6. Security posture
Nothing widens. The bind stays loopback; the tailnet is the authentication boundary;
`.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES`. New surfaces are read-only
probes. `install.sh` still never runs `tailscale serve reset`, still touches only the
mapping it created, and gains one more never: it never advertises a Tailscale Service or
runs `tailscale funnel`. The sudo keep-alive loop is killed by the existing `cleanup` trap.
The rename records the previous name locally and offers the reversal at uninstall.
## 7. Implementation inventory
| File | Change |
| ---- | ------ |
| `install.sh` | New `parse_flags`, `preflight_summary`, `ask_everything` (the three questions), `sudo_session`, `run_step` (spinner + log), `tailscale_preflight`, `tailscale_choose_name`, `tailscale_rename_node`, `print_done_screen`, `print_qr`, `status` subcommand, `name` subcommand. Modified: `main` (reordered into ask -> work -> done), `choose_network_binding` (question 1/3, same defaults), `setup_tailscale_serve` (path/port options), `detect_tailscale_serve_url` (three shapes), `setup_systemd_service`/`setup_launchd_service` (`--base-url`, LaunchDaemon guard), `uninstall` (rename reversal), header docs (flags). Removed from the main flow: the cloudflared prompt, the tunnel-service prompt. bash 3.2 rules unchanged. |
| `src/web/routes/system-routes.ts` | `GET /api/system/remote-access` |
| `src/tailscale-status.ts` (new) | Pure parser for the two JSON shapes + the IO wrapper; unit-tested against captured `serve status --json` fixtures (root, path, port, foreign target, none) |
| `src/config/dependency-registry.ts`, `src/utils/dependency-checker.ts` | `tailscale` doctor row |
| `src/web/public/index.html`, `settings-ui.js`, `panels-ui.js` | Tailscale row + QR, welcome-screen QR |
| `test/install-sh-invariants.test.ts` | Extend: flags documented in the header, no `serve reset`, no `funnel`, no `--service` advertise, every serve mutation goes through `ts_cmd_serve`, rename happens before serve in `main` (static order check) |
| `.github/workflows/ci.yml` | The bash 3.2 step additionally sources the script with stubbed `ts_cmd`/`ts_cmd_serve`/`read_reply` and drives `ask_everything` through all three answers and the 443-occupied menu |
| `test/tailscale-status.test.ts`, `test/routes/system-routes-remote-access.test.ts` | Parser + route |
| Docs | README install + remote-access sections, `docs/wiki/Installation.md`, `Remote-Access.md` (naming options table, Services caveat, path/port variants), `Mobile-Guide.md`, `Running-As-A-Service.md` (macOS login caveat), `FAQ.md`, `docs/security-architecture.md` §A, CLAUDE.md Scripts & Tunnel paragraph, `docs/tailscale-installer-plan.md` gets a pointer here. getcodeman.com copy lives outside the repo (maintainer handbook). |
Changeset: `minor` (new flags, new subcommands, new API route).
## 8. Test plan
Automated (the gate): the static invariants above, the bash 3.2 container drive of the
question phase, the JSON parser fixtures, the route test.
Manual matrix, on a fresh Ubuntu 24 VM and on the Mac mini, since the previous plan's
items never ran on a fresh machine:
1. Tailscale absent, declined -> local-only, done screen shows the retrofit command.
2. Tailscale absent, accepted -> install, login URL, operator, certs toggle polled, rename
question shown (default no), service, serve, URL verified, QR scans on a phone, PWA installs.
3. Tailscale present and logged in on a pre-existing node -> rename default NO, URL is the
node name, `serve status` gains exactly one entry.
4. `:443` root occupied -> path option -> `https://<node>/codeman` answers, hooks still
fire (raw port), `install.sh status` prints the path URL.
5. Rename on a node that already has our serve mapping (`install.sh name`) -> off, rename,
re-add, `serve status` has no stale key.
6. Re-run the one-liner -> quiet update, binding and name preserved, no prompts.
7. `--yes` end to end; `CODEMAN_NONINTERACTIVE=1` end to end (no software installed).
8. Uninstall -> mapping removed, other mappings intact, rename reversal offered.
9. Mac mini: LaunchDaemon left alone with the message; done screen carries the login caveat.
## 9. Phasing and open decisions
**Phase 1 (this round):** the reorder, the three questions, one consent + one sudo, flags,
the done screen with QR, Tailscale preflight-before-build, the path/port answer for an
occupied 443, the rename step, `status` and `name` subcommands, docs.
**Phase 2:** the in-app Tailscale row + QR, `codeman doctor` row, the `remote-access`
route. Independent of phase 1 and useful on its own for existing installs.
**Phase 3 (optional):** replace the bash service writers with `codeman service install`
once that command can carry `CODEMAN_PASSWORD` behind an explicit flag; and a Tailscale
Services flow if a tagged-fleet user asks for `codeman.<tailnet>.ts.net`.
Decisions for the maintainer:
1. **Rename default.** Decided 2026-09-20: always NO; the yes answer, `--name` and
`install.sh name` are the ways in. (The proposal was YES only when this run had joined
the tailnet, NO otherwise; rejected because the host is used for other things.)
2. **Name pattern.** `codeman-<hostname>` (proposed; unique per machine, and two Codemans
on one tailnet stay distinguishable) versus plain `codeman` (nicer once, collides on the
second install, Tailscale silently appends `-1`).
3. **Path versus port** as the default answer for an occupied 443. Proposed: path, because
the URL has no port and `--base-url` already exists for exactly this proxy shape.
4. **Whether Phase 2 ships in the same release.** It is the part that helps people who
installed months ago.
+27
View File
@@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
apply.
## Oversized input (issue #484)
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
#484 the client treated that like a transient failure, so an oversized paste sat
at the head of the queue, was re-sent every 2 s forever, blocked every later
input for the session, and came back from localStorage on each reload.
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
go out in seq order, so the PTY sees one contiguous stream. A paste over
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
split), is refused with a toast and never queued.
- The WebSocket answers an oversized sequenced frame with
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
client that predates `err` reads it as a plain ACK and drops it too.
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
an expired login delivers once the user signs in again).
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
poisoned by an older build heals on the first load after upgrading.
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
`test/input-size-limit.test.ts`.
## Known limitation
Dedup state is in-memory on the server. A **server restart** between a write and
@@ -79,3 +104,5 @@ across the narrow restart window.
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
`(clientId, seq)` once on redelivery; untagged input always applies.
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
splitting, and dropping (never retrying) a frame refused for good (#484).
+11 -4
View File
@@ -270,9 +270,15 @@ tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.net
Only devices on your tailnet can reach it; Tailscale handles identity and
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
push work). No app password and no `0.0.0.0` bind required. (This is the
maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for
automation; the installer never runs `tailscale serve reset` and never touches
serve mappings other than `443 -> Codeman's port`.
maintainer's production setup.) `CODEMAN_TAILSCALE=1` or `--tailscale` presets
the choice for automation. When `:443` on the node already belongs to another
app, the installer mounts Codeman under `/codeman` (`tailscale serve --set-path`
plus `--base-url`, which keeps the loopback bind and the same host guard) or on a
second port rather than replacing it. The installer never runs `tailscale serve
reset`, never touches serve mappings other than the one it created, never opens a
`tailscale funnel` (public internet, a different risk class) and never advertises
a Tailscale Service. Renaming the node (`--name`, `install.sh name`) is opt-in
and defaults to no, because the tailnet name is also the machine's SSH identity.
### B. Authenticated cloudflared tunnel + password
@@ -491,7 +497,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, and three from `~/.grok`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, three from `~/.grok`, and, only when their opt-in switches `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` are `1`, `~/.config/gh/{hosts.yml,config.yml}` and the sign-in files from `~/.azure`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
@@ -510,6 +516,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
---
+198
View File
@@ -0,0 +1,198 @@
# Split-Pane Sessions — Design Spec
**Status**: Implemented (v1)
**Author**: Claude (session with Tim), 2026-09-15
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
## Problem
Codeman's terminal area shows exactly one active session (pane) at a time —
switching panes re-binds the single xterm instance and the single WebSocket
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
`span-codeman.ps1`) turned out to solve a different problem: it makes one
browser window bigger, but that window still shows one session; floating
subagent windows are draggable overlays on top of it, not tiled panes. There
is no way today to see two live sessions (e.g. `w1-codeman` and
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
fit both.
## Goal (v1)
From the active session, open a **second, independent, fully live session**
in a pane beside it — draggable divider, side-by-side only. Closing the
second pane collapses back to today's normal single-pane view. No
persistence: a page reload always returns to single-pane. Floating
subagent/Ultracode windows keep their current behavior unchanged (global,
unconstrained across the whole viewport, split or not).
Explicitly out of scope for v1 (v2 candidates, not designed here):
- More than 2 panes / grid layouts
- Vertical (stacked) splits
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
- Persisting the split layout across reload or across devices
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
to desktop widths the same way `home-sessions.js`'s rail is)
- Feature parity between the two panes (see "Pane B is deliberately plainer"
below)
## Current architecture (why this isn't a CSS change)
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
`this._xtermSnapshots` map used only to restore scrollback into that one
terminal when switching back to a session. Roughly 280 references to this
singleton state exist across the file (input handling, resize/fit, sizing-
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
keyboard accessory bar, link providers, etc.).
Showing two sessions at once therefore requires a second, independently
alive xterm + WebSocket pair running concurrently — not a layout change to
one shared instance.
**Related prior art**: `detachSession(id)` (app.js) already opens one session
in a genuinely separate browser window (`isSoloWindow` mode) with its own
independent WebSocket, and two of those can already be snapped side-by-side
today with zero new code. That covers "two sessions visible at once" but not
what this spec is for: one Codeman window with two panes and a divider you
can drag without leaving your seat, each still a full participant in that
window's floating subagent windows, header, and settings. This spec builds
past detach, not a duplicate of it.
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
(`ws-connection-registry.ts`). Splitting always opens a *different* session
in the second pane (self-splitting is disallowed, see below), so this is two
sessions each getting their normal one connection — the existing cap is
irrelevant here and needs no server change.
## Key design decision: Pane B is deliberately plainer than Pane A
Porting all ~280 singleton behaviors to a second, symmetric pane is not
worth it for v1 — most of that code is input-quality-of-life for **mobile/
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
keyboard accessory bar), and this feature is desktop-only by nature (a split
view needs a wide viewport). So:
- **Pane A** (the session that was already active when you opened the split)
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
code path, zero regression risk.
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
resize-on-divider-drag, and plain keyboard input. It does **not** get the
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
keyboard accessory bar. On a desktop, typing directly into an xterm
instance with no overlay is exactly how Codeman behaved before the local-
echo overlay existed for touch devices — normal, not degraded, for a
keyboard-and-mouse user.
If this asymmetry actually bothers you in daily use, promoting Pane B to full
parity is a scoped v2 (extract the shared logic already once you have two
call sites to compare, rather than guessing the right abstraction now).
One more asymmetry worth naming here rather than discovering by surprise:
while both panes accept keyboard input, the global capture-phase shortcut
handler (`app.js`) always resolves against Pane A — it has no notion of
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
focus clears or closes Pane A, not the session you were actually typing
into. Not fixed for v1, same reasoning as the rest of this section.
## Components
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
A small class, one instance per secondary pane:
- `constructor(sessionId, mountEl)`
- `connect()` — creates the xterm instance (same theme/font config as the
primary, read from the same settings so it doesn't visually clash), opens
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
on window resize
- `destroy()` — disposes the xterm instance, closes the WS cleanly
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
there's no persistence requirement.
### 2. Split container (layout)
```
.terminal-split-container (flex row, only rendered when split is active)
├── .terminal-wrap (existing element, Pane A — untouched)
├── .split-divider (new, draggable seam)
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
small header: session name + × close button)
```
When not split, `.terminal-wrap` renders exactly as it does today (no
wrapping container at all, to keep the no-split path byte-identical to
current behavior). Splitting inserts the container and reparents
`.terminal-wrap` into it as the first child — same reparenting pattern
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
a new pattern for the codebase.
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
unusably thin sliver.
### 3. Trigger UI
A **"Split"** button (header, opt-in like the other header buttons —
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
opens a small picker listing your other open sessions (reuses
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
session — you cannot split a session against itself). Picking one:
1. Creates the split container, reparents `.terminal-wrap`
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
3. Button state flips to "close split" (or Pane B's own header × does it)
Closing (via Pane B's × or the header button toggling off):
1. `SplitTerminalPane.destroy()`
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
its original location at 100% width
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
place today — no new code needed here, it fires naturally once the
container's size changes)
v2 note (not designed): dragging a session tab onto the active pane as an
alternate trigger. You confirmed right-click doesn't work today (Codeman
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
### 4. Failure / edge cases
- **The Pane B session ends or is deleted while split is active** → treat
identically to the user closing Pane B manually: destroy the pane, collapse
to Pane A at full width.
- **The Pane A session ends while split is active** → Pane B is promoted:
it becomes the new single full-width pane (reusing today's normal
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
a real `this.terminal`/`this._ws` binding — simplest correct approach is
to just collapse the split and let normal session-select logic reopen
Pane B's session as the new primary, rather than trying to promote the
lightweight pane object in place).
- **Both end** → falls through to today's normal "no active session" /
welcome-screen state.
- **Subagent/Ultracode floating windows** → no design work needed; they're
already positioned independent of `.terminal-wrap`'s layout, so they
continue to float over whichever pane(s) are on screen, unconstrained,
exactly as today.
## Testing
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
existing terminal tests use `TEST_PTY_SCRIPT`).
- Route/integration: opening two WS connections to two different sessions
from one simulated client concurrently — confirms the existing per-session
cap and connection registry need no changes.
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
UI): open split via button+picker, verify both panes render live output
independently, drag divider and confirm both refit, close Pane B and
confirm Pane A returns to full width, kill the Pane B session externally
and confirm auto-collapse.
## Open questions for review
None blocking — the scope-narrowing decisions above (Pane B feature parity,
no persistence, side-by-side only, button+picker trigger) came directly from
your answers during brainstorming. Flag anything here you want reconsidered.
+5
View File
@@ -1,5 +1,10 @@
# Tailscale Setup in the Installer (Plan)
> Superseded in part by [`installer-v2-plan.md`](installer-v2-plan.md) (2026-09-20), which
> moved every human step before the build, added the sub-path / second-port answer for an
> occupied `:443`, the opt-in rename, flags, and the done screen with a QR code. The
> state machine and safety rules below still hold.
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
`install.sh`, instead of a one-line hint pointing at the docs. Today the safest
recommended deployment (loopback bind + `tailscale serve`) is exactly what the
+4
View File
@@ -40,6 +40,10 @@ A **MAJOR** bump is required to break any of these after 1.0:
optional fields, new error codes, new SSE events) are non-breaking; breaking
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
is kept working for the bundled UI.
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
`#session=<id>` fragment selects that session if this client can see it. The
fragment name and that meaning are stable; see
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
## What SemVer does NOT cover (internal surfaces — may change in any release)
+4 -1
View File
@@ -76,7 +76,7 @@ output. The other CLIs expose no equivalent.
| Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No |
| Model, effort, and ultracode controls | Yes | No |
| Model, effort, advisor, and ultracode controls | Yes | No |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
@@ -96,6 +96,9 @@ The defaults you will care about, all under **App Settings**:
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
environment variable, because that would hard-lock it and block in-session switching.
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
default: `/advisor` switches it or turns it off inside the session.
- **Startup permission mode** (Agents & CLIs section). The default is
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
+7
View File
@@ -42,10 +42,17 @@ npm run typecheck
npm run lint
npm run format:check
npm run check:frontend-syntax
npm run check:browser-excludes
npm test -- test/<file>.test.ts # one file, the normal way
npm run test:ci # the full CI sweep
```
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
machine-dependent) and blocks a push that would fail them. It skips itself when you push
something other than the checked-out HEAD, or when the tree has uncommitted changes the
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
`pre-push` hook of your own is never overwritten.
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
suites that need a live server, Chromium, and environment-specific baselines; they hang or
fail on a normal machine. `test:ci` is the honest "run everything".
+8 -1
View File
@@ -20,12 +20,19 @@ Three ways to get one, all under **+** next to the case picker:
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Clone Repo** | A public repo cloned into `~/codeman-cases/<name>` and registered as a case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
registration, and for a linked case that is all it removes.
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
then signing those CLIs in once from a shell session is enough. See the private repositories
section of `docker/README.md`. Without credentials a private repo fails straight away with an
authentication error.
**Cases created from scratch are the only copy of that code.** Uninstalling Codeman does not
delete `~/codeman-cases/`, but treat that directory as real work, not scratch space.
+7 -1
View File
@@ -50,7 +50,13 @@ pointed at.
the session launches straight away on that model — nothing to choose. With two or more, a
small dialog asks which one to use for this launch before starting the session; the
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
use a different one without changing the saved default.
use a different one without changing the saved default. The list is not raw discovery order
either: the model llama-swap reports loaded and ready is moved to the top and tagged
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
another device starts from its own history). The default model keeps its own **Default** pill
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
thumb.
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
straight onto the endpoint** — no restart, because the endpoint is applied before the
+35
View File
@@ -118,6 +118,41 @@ 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.
**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
seeded like everything else, file by file: `~/.config/gh/hosts.yml` and `config.yml`, and the
sign-in files from `~/.azure` (not its logs or extensions). With a switch off they are never
copied in, even if the files exist. So once a switch is on and `gh auth login` / `az login`
have been run where Codeman runs, agents in a Docker case can clone and push private repos on
those hosts. Two limits:
- A token held in a desktop keyring or an encrypted token cache (Windows, macOS) is not
inside those files and does not carry in. Sign in inside the container instead. The Docker
server image and a headless Linux host keep it in the files, so they carry.
- The sign-ins are mounted when a case container is **created**, so an existing container
never picks them up. After turning a switch on, signing in, or rebuilding the agent image,
**recreate the case container**: remove it, and the next session in that case creates a
fresh one. (Or sign in inside the existing container instead.)
This hands a GitHub token and an Azure sign-in to every agent in a seeded Docker case, the
same trust you already give it with Claude, Codex or gcloud. Turn seeding off for a case that
should not have them.
Both CLIs are opt-in. To build the agent image with them, set
`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` and/or `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` where the image
is built: in front of `node scripts/build-agent-image.mjs`, or in the Codeman server's
environment for the image it builds automatically (in the Docker deployment, `environment:`
in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
`docker/README.md` ("Private repositories") has the details and the matching switches for
the server image.
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
## Isolation
Every container runs hardened by default:
+47 -13
View File
@@ -24,16 +24,22 @@ This installs Node.js, tmux and a build toolchain if they are missing (node-pty
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
builds it.
What it asks you:
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
an existing install), then asks everything it needs up front, then does the work
unattended. You can leave while it builds. What it asks you:
1. **Permission for every system change.** Package installs and agent CLI downloads are
prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
offers to install any of them (DeepSeek excepted: its npm package installs only a
launcher with no runnable profile), or you skip and install one yourself later.
1. **One consent for the missing packages.** Git, Node.js, tmux and (on Linux) the build
toolchain are installed after a single yes, and sudo asks for your password once for
the whole run. Nothing is installed silently. If no agent CLI is found, a menu offers
to install any of them (DeepSeek excepted: its npm package installs only a launcher
with no runnable profile), or you skip and install one yourself later.
2. **How the dashboard should be reachable.** Three choices:
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
end to end.
- **Tailscale** (recommended for phone access): keeps the loopback bind, installs
Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin
page for you and waits; Ctrl+C there skips Tailscale for this run), then configures `tailscale serve` after the build and
verifies the result end to end. If another app already owns `:443` on your node,
you choose between a sub-path (`https://<machine>.<tailnet>.ts.net/codeman`, the
default), a second port, replacing the other mapping, or skipping.
- **Your local network** (`0.0.0.0`): prompts for a password. Skipping the password takes
an explicit confirmation and ends on a loud warning.
- **This machine only** (`127.0.0.1`): the safest option, and the default for a bare
@@ -44,26 +50,54 @@ What it asks you:
Tailscale. An existing loopback install defaults to keeping loopback, or to Tailscale when
a serve mapping for Codeman is already there. A bare Enter never pulls in new software,
and a non-interactive run always keeps the safe loopback default.
3. **What to do when it finishes.** Run in this terminal, install as a background service
that starts on boot, or do nothing yet.
3. **What to call this machine on your tailnet** (Tailscale route only). By default the URL
uses the machine's existing name. Answer yes to rename it `codeman-<hostname>`; the
default is no, because the tailnet name is also what SSH and everything else on that
machine are reached by.
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
instead, or not at all.
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
scan with your phone, and the two commands you need to manage the service.
Re-running the same one-liner **updates an existing install in place**. Local changes in
`~/.codeman/app` are stashed rather than discarded, a running service is restarted and
verified, and your existing network binding is preserved. An interrupted first install
resumes instead of restarting.
Two other entry points exist:
Other entry points:
```bash
install.sh status # print the URLs, the QR code and the manage commands again
install.sh update # update only
install.sh uninstall # remove
install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
```
**Flags** answer the questions from the command line and pipe through `bash -s --`:
```bash
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
```
`--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename`
the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every default
(it still waits on a Tailscale login URL, and a network bind still asks for a password).
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively.
installs Tailscale itself non-interactively; a non-interactive run never renames the
machine and never starts a service. Everything the unattended steps print goes to
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
## Route B: npm
+12 -6
View File
@@ -60,14 +60,20 @@ On by default; it can be turned off in settings.
A row of keys above the virtual keyboard, and what it contains depends on the session.
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
a path picker, an image key, and 🧠 when Read My Mind is on. 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.
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
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 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.
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
arrows, a direct Paste key (shell input is not an agent prompt, so there is no Compose
there), and dismiss. Your normal preference is remembered and restored when you
switch back to an agent session, so a settings change during a shell session cannot strip
the bar away permanently.
+28
View File
@@ -103,6 +103,34 @@ locked phone and the agent continues.
With the inbox off, the buttons are stripped from the notification payload entirely rather
than being shown and failing.
## When a session is watching its own work
An agent that starts a monitor, puts a shell in the background or hands a task to a cloud
session is told by its CLI to end the turn and wait to be notified. The pane then goes
quiet, and the CLI's idle notification arrives about a minute later — for a session that
wants nothing from you.
Codeman reads what the CLI prints about its own background work and treats that prompt
differently. It raises no tab alert, no desktop notification and no push, the session stays
out of NEEDS YOU on every surface, and the row wears a blue **watching** badge instead. Hover
it, or read it on a phone through your screen reader, and it says what is running: "1
monitor", "2 shells", "1 background terminal".
The prompt itself is not thrown away. It sits in the Approvals drawer as an ordinary card,
still answerable, with a line reading "quiet, watching 1 monitor" where a card you had
already looked at would say nothing. The next time that session goes quiet for an ordinary
reason, it alerts you exactly as before.
Two limits are worth knowing. A permission prompt or a question dialog still goes red
whatever else the agent started, because that one blocks it outright. A question asked in
plain prose is not a dialog, so an agent that starts a monitor and then writes "which branch
should I target?" is quiet along with the rest — check a watching session yourself if it has
been quiet longer than the work it is waiting for should take.
An agent waiting for your comments on an artifact it published never counts as watching.
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
comment, so the session alerts you like any other quiet session.
## The phone overview
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
+1 -1
View File
@@ -43,7 +43,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
| Tab | Use it when |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Clone Repo** | Working on an existing public repo. Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
The gear next to the picker holds two per-case toggles: **Agent Teams** and
+24 -3
View File
@@ -33,11 +33,12 @@ Your devices join a private network, and Codeman stays bound to loopback. Nothin
published to the internet, and you get real HTTPS with a real certificate.
The installer sets this up for you, including installing Tailscale, logging in, enabling
tailnet HTTPS, and verifying the result end to end. To retrofit it onto an existing
install:
tailnet HTTPS, and verifying the result end to end. It ends on the URL with a QR code to
scan. To retrofit it onto an existing install, or to see the URL and QR code again:
```bash
install.sh tailscale
install.sh status
```
By hand:
@@ -49,6 +50,22 @@ tailscale serve status
Then open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet.
### The name in the URL
The URL is the machine's MagicDNS name, so on a machine called `tnode` it is
`https://tnode.<tailnet>.ts.net`. Three ways to influence that, from least to most work:
| You want | How |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| The machine's existing name (default) | Nothing. This is what the installer does unless you say otherwise. |
| `https://codeman-<hostname>.<tailnet>.ts.net` | Answer yes to the installer's name question, pass `--name codeman-<hostname>`, or run `install.sh name`. This renames the machine tailnet-wide (SSH included), which is why the installer defaults to no. `install.sh uninstall` offers to rename it back. |
| `https://codeman.<tailnet>.ts.net` | A [Tailscale Service](https://tailscale.com/docs/features/tailscale-services). Only a **tagged** node can host one (a device signed in with a user account cannot), the service is defined and approved in the admin console, and the feature is in beta. The installer does not set this up; it is a `tailscale serve --service=svc:codeman --https=443 127.0.0.1:3000` on a tagged host once the service exists. |
If `:443` on your node already belongs to another app, the installer offers Codeman under
`https://<machine>.<tailnet>.ts.net/codeman` (the default, via `tailscale serve --set-path`
plus Codeman's `--base-url`), on a second port (`https://<machine>.<tailnet>.ts.net:8443`),
or replacing the other mapping. It never replaces anything without asking.
Notes:
- Keep the loopback bind. `tailscale serve` connects to `127.0.0.1:3000` locally, so
@@ -58,7 +75,11 @@ Notes:
- Codeman's Host-header allowlist already accepts `.ts.net`, so no extra configuration is
needed.
- The installer never resets or rewrites `serve` mappings other than the one pointing at
Codeman's port, so unrelated serve configuration is left alone.
Codeman's port, so unrelated serve configuration is left alone. It also never opens a
`tailscale funnel` (that is the public internet) and never advertises a Tailscale Service.
- On macOS, the App Store and standalone Tailscale apps only run once someone is logged in,
so a headless Mac needs the open-source `tailscaled` for the URL to come back after a
reboot on its own.
## Cloudflare tunnel
+8
View File
@@ -67,6 +67,14 @@ On Linux, if you want the service running while you are not logged in:
loginctl enable-linger $USER
```
On macOS, a LaunchAgent starts when you log in, not at boot. A headless Mac (no GUI login)
needs a system LaunchDaemon instead, written by hand as root. The installer recognises an
existing `/Library/LaunchDaemons/com.codeman.web.plist` and leaves it alone rather than
installing a LaunchAgent next to it, since the two would fight over the port; remove the
daemon first if you want to switch. The same login caveat applies to the App Store and
standalone Tailscale apps, so on a headless Mac the Tailscale URL only comes back after a
reboot if the open-source `tailscaled` is used.
### Writing the unit by hand
**Linux (systemd user unit):**
+20 -8
View File
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
| Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. |
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
@@ -55,12 +56,14 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
Manager, Attachments, File Viewer, Multi-monitor, Plan Usage, Lifecycle Log, Monitor,
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
New header controls never appear on phones.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go.
This section also holds background-agent tracking, including whether to track agents for
every session or only the active tab.
@@ -84,13 +87,22 @@ every session or only the active tab.
### Models
Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
and the switch compose into one model choice, so there is no separate "which one wins"
question.
Claude model cards, the 1M context window switch, the thinking effort segment and the
advisor segment. The cards and the switch compose into one model choice, so there is no
separate "which one wins" question.
Model and effort are both **soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
inside a session override them at any time.
Model, effort and advisor are all **soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
`/effort` and `/advisor` inside a session override them at any time.
**Advisor** gives new Claude sessions Claude Code's
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
consults before committing to an approach, when an error keeps coming back, and before it
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
which costs less than running the stronger model all the time. **Default** leaves it to
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
attached.
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
+9 -4
View File
@@ -48,6 +48,7 @@ One tab per session, in your order, and that order syncs across your devices.
| Yellow tab, blinking | The agent is waiting for input from you. |
| Red tab, blinking | A question or permission prompt is blocking the session. |
| No dot | The session is not running. |
| Muted grey dot plus an `exited (137)` badge | The agent inside the pane has exited, with that exit code (or `exited (signal 9)`). A bare `exited` means tmux saw the pane die but did not report how, which is not the same as a clean `exited (0)`. Detailed sidebar and rail rows read `exited` in their pill. |
![Tab alerts](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/tab-alerts-20260815.png)
@@ -116,6 +117,7 @@ The right side of the header. Almost all of these are off until you enable them
| Lifecycle Log | Off | Session start, exit, and kill audit trail. |
| Cron ⏰ | Off | Scheduled jobs. |
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. |
@@ -149,10 +151,13 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
and automatic output recovery stay within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
+5 -2
View File
@@ -164,8 +164,11 @@ Scrollback behaviour depends on the CLI, and Codeman adjusts what it strips per
Things to try:
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
- On Claude sessions with a recent CLI, the wheel is forwarded into Claude's own transcript,
so it scrolls the conversation rather than the terminal buffer. That is intended.
- On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is
forwarded into Claude's own transcript, so it scrolls the conversation rather than the
terminal buffer. That is intended. Claude's default inline view scrolls locally; turn
fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in
`~/.claude/settings.json`.
- Scrolling to the very top pulls the full tmux scrollback again on demand.
### The wheel does nothing in a Codex session
+5 -3
View File
@@ -13,7 +13,8 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| PDF and Office documents | Converted for preview when a converter is available. |
@@ -85,8 +86,9 @@ File paths in a session are links. That works in two places:
render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
the tail viewer instead, which follows a file that is still being written.
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
viewer instead, which follows a file that is still being written.
Paths **outside** the session's workspace work too, which matters because that is where most
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
+1479 -554
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.31.0",
"version": "1.33.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.31.0",
"version": "1.33.3",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -55,6 +55,7 @@
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.8",
"@xterm/headless": "^6.0.0",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.31.0",
"version": "1.33.3",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -28,6 +28,7 @@
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
@@ -123,6 +124,7 @@
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.8",
"@xterm/headless": "^6.0.0",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
+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.31.0",
"version": "1.33.3",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+26 -8
View File
@@ -47,7 +47,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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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) ----
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
@@ -196,6 +196,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -207,12 +212,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -372,10 +384,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.30.1
CODEMAN_PREAMBLE=1.33.4
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
@@ -426,7 +438,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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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'
@@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
stronger model it consults before committing to an approach, on a recurring error and
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
below the worker's own model is never attached (on an Opus worker only `opus` and
`fable` do anything).
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
+16 -4
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -129,12 +134,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -294,4 +306,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.30.1
CODEMAN_PREAMBLE=1.33.4
@@ -340,11 +340,18 @@ ESC=$(printf '\033')
### Starting a worker
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; 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.
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
model id): Claude Code's advisor tool, a stronger model the worker consults before
committing to an approach, on a recurring error and before declaring the task done. It is
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
`CODEMAN_WORKER_ADVISOR` is set.
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`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`,
@@ -384,7 +391,7 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`envOverrides`). Three differences that break copied code:
`advisorModel`, `envOverrides`). Three differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
@@ -101,11 +101,16 @@ the case name, read it from the listing.
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
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. The flag is fail-closed (older/unknown CLI omits it, because an
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
`/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
`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
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
@@ -196,8 +201,8 @@ idle:
The contract an orchestrator follows for any fleet of two or more messaging workers.
Every topology in the next section is this protocol plus a wiring diagram.
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
`--name` gate above). Session create installs the hooks block into the workspace
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
@@ -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.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
[§5.2](#52-readiness)).
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
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 `sessionName`
in quick-start to pick it; older setups list a name derived from the case folder.
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.
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
+34
View File
@@ -146,10 +146,44 @@ console.log('\n[build] content-hash cache busting');
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
}
writeFileSync(join(distPublic, 'index.html'), html);
// Rewrite sw.js from the SAME manifest that just renamed the files.
//
// The service worker's precache list used to be maintained by hand with the
// pre-hash names, so after this step every entry in it pointed at a file that
// no longer existed and `cache.add(...).catch(() => {})` hid it. Deriving it
// here is the only way the two cannot drift.
//
// The cache key gets the build hash for the same reason: `activate` deletes
// every cache that is not the current one, so a constant key meant that
// cleanup never ran and hashed assets from every past release piled up.
const swPath = join(distPublic, 'sw.js');
let sw = readFileSync(swPath, 'utf8');
const hashedAssets = Object.values(manifest);
const buildId = createHash('md5').update(hashedAssets.join('|')).digest('hex').slice(0, 12);
// Rewrite the two declarations. Anchored on the full `const … = …;` text so
// each pattern occurs exactly once and cannot collide with prose in sw.js's
// own comments — an earlier cut used bare `__BUILD_ID__` sentinels and the
// first match landed in the comment that documented them, leaving the real
// constant untouched and still producing a plausible-looking cache key.
const swEdits = [
["const BUILD_ID = 'dev';", `const BUILD_ID = '${buildId}';`],
['const HASHED_ASSETS = [];', `const HASHED_ASSETS = [${hashedAssets.map((p) => JSON.stringify(p)).join(', ')}];`],
];
for (const [from, to] of swEdits) {
const hits = sw.split(from).length - 1;
if (hits !== 1) {
throw new Error(`sw.js: expected exactly one \`${from}\`, found ${hits} — precache would ship stale`);
}
sw = sw.replace(from, to);
}
writeFileSync(swPath, sw);
console.log(' Hashed files:');
for (const [orig, hashed] of Object.entries(manifest)) {
console.log(` ${orig} -> ${hashed}`);
}
console.log(` sw.js: cache bucket codeman-${buildId}, ${hashedAssets.length} precached assets`);
}
// 6. Compress with gzip + brotli
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* Browser-test exclusion check.
*
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
* clean checkout) have no chromium, so such a file dies with
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
* BY HAND, and a new browser test simply does not appear in it unless someone
* remembers. The omission is invisible on a developer machine that has run
* `npx playwright install`, where the test passes, and only shows up on a clean
* runner.
*
* Two deliberate design choices:
*
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
* would miss the browser tests that predate that convention
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
* file dangerous is importing a browser driver, so that is what is tested.
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
*
* 2. **The exclusion side is answered by vitest itself**, via
* `vitest list --filesOnly`, rather than by re-implementing glob matching
* against the config's `exclude` array. Patterns there include `test/mobile/**`
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
* edge case would report a gap that does not exist, or miss one that does.
* Asking the real resolver cannot drift from the real behaviour.
*
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
* check itself only runs when this file is executed directly.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname, relative, sep, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
const SUITES_FILE = join('config', 'test-suites.ts');
/** Importing any one of these means the test needs a real browser binary. */
const BROWSER_DRIVER =
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
/** @param {string} source */
export function importsBrowserDriver(source) {
return BROWSER_DRIVER.test(source);
}
/** @param {string} dir @returns {string[]} */
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(path));
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
}
return out;
}
/**
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
* `vitest list` prints).
*
* @param {string} root
* @returns {string[]}
*/
export function findTestFiles(root) {
return walk(join(root, 'test'))
.map((file) => relative(root, file).split(sep).join('/'))
.sort();
}
/**
* The subset of {@link findTestFiles} that imports a browser driver.
*
* @param {string} root
* @returns {string[]}
*/
export function findBrowserTests(root) {
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
}
/**
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
* blank or decorative lines are ignored rather than assuming the format is pristine.
*
* @param {string} output
* @returns {Set<string>}
*/
export function parseVitestFileList(output) {
return new Set(
output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.endsWith('.test.ts'))
.map((line) => line.replace(/^\.\//, ''))
);
}
/**
* Whether the `vitest list` paths and the walked tree name at least one file in common.
* False means the two sides are not speaking the same path format (absolute paths, backslashes
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
* against a perfectly non-empty listing.
*
* @param {Set<string>} ciFiles
* @param {string[]} testFiles
*/
export function listingMatchesTree(ciFiles, testFiles) {
return testFiles.some((file) => ciFiles.has(file));
}
/**
* @param {string[]} browserTests
* @param {Set<string>} ciFiles
* @returns {string[]} browser-driven files that the CI config would still collect
*/
export function findLeaks(browserTests, ciFiles) {
return browserTests.filter((file) => ciFiles.has(file));
}
function main() {
const testFiles = findTestFiles(ROOT);
const browserTests = findBrowserTests(ROOT);
let collected;
try {
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err) {
console.error('✗ could not enumerate the CI test set via `vitest list`.');
console.error(err.stderr ? err.stderr.toString() : String(err));
process.exit(1);
}
const ciFiles = parseVitestFileList(collected);
if (ciFiles.size === 0) {
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
process.exit(1);
}
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
if (!listingMatchesTree(ciFiles, testFiles)) {
const sample = [...ciFiles].slice(0, 3).join(', ');
console.error(
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
);
process.exit(1);
}
const leaked = findLeaks(browserTests, ciFiles);
if (leaked.length > 0) {
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
for (const file of leaked) console.error(` ${file}`);
console.error(`
These import a browser driver, so on a runner with no chromium they fail with
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
it, and \`npm run test:browser\` its includes).
They may well pass on this machine; that is the trap. To reproduce a clean
runner locally:
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
process.exit(1);
}
console.log(
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
+253
View File
@@ -0,0 +1,253 @@
/**
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
* pinned by test/git-hooks.test.ts.
*
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
*
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
* within a day. The checks below mirror the static CI job.
*
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
* the second case is usually another session's WIP, which must not block this push.
*
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
* survive `npm install`.
*/
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
/**
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
* A changed body still reaches installed hooks, because the refresh compares the whole file.
*/
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
/**
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
* is the argument list for `npm run`, and each is a step of the static job in
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
*/
export const PRE_PUSH_CHECKS = [
['check:lockfile'],
['generate:cli-catalog', '--', '--check'],
['check:browser-excludes'],
['check:frontend-syntax'],
['format:check'],
['lint'],
['typecheck'],
];
/**
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
* Derived from what each check reads: src/ (format:check, lint, typecheck,
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
* --check diffs its generated block), tsconfig.json (typecheck, and
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
* (format:check; the Prettier CLI honours .editorconfig by default).
*/
export const PRE_PUSH_WATCHED_PATHS = [
'src',
'config',
'scripts',
'test',
'package.json',
'package-lock.json',
'install.sh',
'tsconfig.json',
'.prettierignore',
'.editorconfig',
];
/**
* Render the pre-push hook script.
*
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
*/
export function renderPrePushHook() {
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
return `#!/bin/sh
${PRE_PUSH_MARKER}
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
# it is regenerated on npm install. Delete the marker line above to take ownership
# and the installer will leave your version alone.
#
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
# Skip always: remove this file.
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$repo_root" || exit 0
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
# npm install). Warn rather than blocking the push on a setup detail.
if [ ! -d node_modules ]; then
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
exit 0
fi
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
# The checks below read the working tree, so they only say something about a pushed commit
# that IS the checked-out HEAD (tags are peeled to their commit first).
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
has_content=0
not_head=''
while read -r localref localsha _remoteref _remotesha; do
[ -z "$localsha" ] && continue
case "$localsha" in
0000000000000000000000000000000000000000) ;;
*)
has_content=1
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
;;
esac
done
[ "$has_content" = "0" ] && exit 0
if [ -n "$not_head" ]; then
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
exit 0
fi
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
exit 0
fi
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
trap 'rm -f "$log"' EXIT
failed=''
run_check() {
if ! npm run --silent "$@" >"$log" 2>&1; then
echo ""
echo "pre-push: FAILED npm run $*"
tail -n 25 "$log"
failed="$failed $1"
fi
}
echo "pre-push: running static checks (~10-40s)..."
${runs}
if [ -n "$failed" ]; then
echo ""
echo "pre-push: blocked by:$failed"
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
exit 1
fi
echo "pre-push: static checks passed."
exit 0
`;
}
/**
* Decide what to do with an existing hook file.
*
* @param {{ existing: string | null | undefined, next: string }} args
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
*/
export function planHookInstall({ existing, next }) {
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
return existing === next ? 'up-to-date' : 'write';
}
/** @param {string} cwd @param {string[]} args */
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}
/**
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
*
* @param {string} path
*/
function canonicalPath(path) {
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
}
/**
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
* is nothing to install into.
*
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
* `.git` is a FILE pointing at the parent repo, so the hooks live under
* `--git-common-dir`.
*
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
* shared hooks directory used by every repo on the machine); installing there would
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
* the comparison is on canonical paths rather than on whether the setting exists.
*
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
*
* @param {string} repoRoot
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
// Both are printed relative to the cwd (repoRoot) unless already absolute.
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
if (!hooks || !common) return null;
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
} catch {
return null;
}
}
/**
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
* {@link planHookInstall}: a hook without the marker is never touched.
*
* @param {string} hooksDir
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
*/
export function installPrePushHook(hooksDir) {
const path = join(hooksDir, 'pre-push');
const next = renderPrePushHook();
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
const action = planHookInstall({ existing, next });
if (action === 'write') {
mkdirSync(hooksDir, { recursive: true });
writeFileSync(path, next, { mode: 0o755 });
chmodSync(path, 0o755); // `mode` only applies when the file is created
}
return action;
}
+55 -3
View File
@@ -55,9 +55,61 @@ export function agentImageNpmPackages(catalog) {
return packages;
}
/** The `--build-arg` pairs the agent image takes. PURE. */
export function agentImageBuildArgPairs(catalog) {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')]];
/**
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
* ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
*/
export const GIT_HOST_CLI_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* Environment variable → Dockerfile ARG for the image's system Git identity.
* ⚠️ Mirrored by `GIT_IDENTITY_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
*/
export const GIT_IDENTITY_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
];
/**
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
* as before these existed; anything other than 0/1 is refused rather than guessed at.
*/
export function gitHostCliBuildArgPairs(env) {
const pairs = [];
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
const value = env[envName];
if (value === undefined || value === '') continue;
if (value !== '0' && value !== '1') {
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
}
pairs.push([argName, value]);
}
return pairs;
}
/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */
export function gitIdentityBuildArgPairs(env) {
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']);
const configured = pairs.filter(([, value]) => value !== '');
if (configured.length === 0) return [];
if (configured.length !== pairs.length) {
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
throw new Error(`${names} must both be set when configuring Git identity`);
}
return pairs;
}
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(catalog, env = process.env) {
return [
['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')],
...gitHostCliBuildArgPairs(env),
...gitIdentityBuildArgPairs(env),
];
}
/** Read the committed catalogue. IO. */
+16 -4
View File
@@ -356,14 +356,17 @@ if (!isGlobalInstall) {
}
// ----------------------------------------------------------------------------
// 5. Install git pre-commit hook (format check)
// 5. Install git hooks (pre-commit format check, pre-push static checks)
// ----------------------------------------------------------------------------
if (!isGlobalInstall) {
try {
const { writeFileSync, mkdirSync } = await import('fs');
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
// null when this directory is not the top of a git checkout.
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
if (gitHooksDir) {
mkdirSync(gitHooksDir, { recursive: true });
const hook = `#!/bin/bash
# Auto-installed by postinstall — prevents CI format failures
@@ -379,9 +382,18 @@ fi
const hookPath = join(gitHooksDir, 'pre-commit');
writeFileSync(hookPath, hook, { mode: 0o755 });
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
// hook the developer wrote themselves is left alone.
const action = installPrePushHook(gitHooksDir);
if (action === 'write') {
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
} else if (action === 'skip-foreign') {
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
}
}
} catch {
// Non-critical — git hook is a convenience
// Non-critical — git hooks are a convenience
}
}
+20 -1
View File
@@ -74,6 +74,15 @@ echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO"
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
export GIT_TERMINAL_PROMPT=0
# --node is the server's process.execPath, a VERSIONED path (Homebrew resolves it
# into Cellar/node/<ver>/). A `brew upgrade node` under a long-running server
# deletes it, and every status write then failed, so the status stayed "queued"
# forever. Fall back to whatever node is on PATH.
if [ ! -x "$NODE" ]; then
echo "[self-update] WARN: $NODE is not executable, falling back to node on PATH"
NODE="$(command -v node || echo node)"
fi
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
STASH_REF=""
MANUAL_CMD=""
@@ -276,7 +285,17 @@ case "$SUPERVISOR" in
# domain needs root, but we don't need it — kill the server and launchd
# respawns it on the new dist/ within ThrottleInterval seconds.
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
: # respawn is launchd's job from here
# Respawn is launchd's job, but only once the old process EXITS. A graceful
# shutdown that hangs leaves the port closed and the service down, so
# escalate to SIGKILL (tmux sessions live outside the server and survive).
for _ in $(seq 1 30); do
kill -0 "$SERVER_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "[self-update] server pid $SERVER_PID still alive 30s after SIGTERM, sending SIGKILL"
kill -9 "$SERVER_PID" 2>/dev/null || true
fi
else
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
+26 -8
View File
@@ -47,7 +47,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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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) ----
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
@@ -196,6 +196,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -207,12 +212,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -372,10 +384,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.30.1
CODEMAN_PREAMBLE=1.33.4
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
@@ -426,7 +438,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.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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'
@@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
stronger model it consults before committing to an approach, on a recurring error and
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
below the worker's own model is never attached (on an Opus worker only `opus` and
`fable` do anything).
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
+16 -4
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -129,12 +134,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -294,4 +306,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.30.1
CODEMAN_PREAMBLE=1.33.4
+9 -2
View File
@@ -340,11 +340,18 @@ ESC=$(printf '\033')
### Starting a worker
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; 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.
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
model id): Claude Code's advisor tool, a stronger model the worker consults before
committing to an approach, on a recurring error and before declaring the task done. It is
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
`CODEMAN_WORKER_ADVISOR` is set.
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`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`,
@@ -384,7 +391,7 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`envOverrides`). Three differences that break copied code:
`advisorModel`, `envOverrides`). Three differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
+10 -5
View File
@@ -101,11 +101,16 @@ the case name, read it from the listing.
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
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. The flag is fail-closed (older/unknown CLI omits it, because an
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
`/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
`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
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
@@ -196,8 +201,8 @@ idle:
The contract an orchestrator follows for any fleet of two or more messaging workers.
Every topology in the next section is this protocol plus a wiring diagram.
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
`--name` gate above). Session create installs the hooks block into the workspace
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { 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
+3 -2
View File
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
[§5.2](#52-readiness)).
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
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 `sessionName`
in quick-start to pick it; older setups list a name derived from the case folder.
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.
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
+45
View File
@@ -0,0 +1,45 @@
/**
* @fileoverview Carry a Codeman rename into Claude Code's own session title.
*
* Claude Code keeps a conversation's title in its transcript as a
* `{"type":"custom-title"}` row (what `/rename` writes), last row wins, and the
* `/resume` picker shows `customTitle ?? aiTitle`. Renaming a tab in Codeman
* 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.
*
* @module claude-session-title
*/
import fs from 'node:fs/promises';
/**
* Append a `custom-title` row for `conversationId` to an existing transcript.
* Never creates the file: a missing transcript means the conversation has not
* been written yet, and a file of only a title row would show up in `/resume`
* as an empty conversation. Returns whether a row was written.
*/
export async function appendClaudeCustomTitle(
transcriptPath: string,
conversationId: string,
title: string
): Promise<boolean> {
const customTitle = title.trim();
// Claude reads the row through `customTitle ?? aiTitle`, so an empty string
// would blank the picker entry rather than fall back to the generated title.
if (!customTitle) return false;
try {
if (!(await fs.stat(transcriptPath)).isFile()) return false;
} catch {
return false;
}
// One O_APPEND write of one line, the same way Claude appends its own rows,
// so it cannot interleave with a row the live process is writing.
const row = JSON.stringify({ type: 'custom-title', customTitle, sessionId: conversationId });
await fs.appendFile(transcriptPath, `${row}\n`);
return true;
}
+10
View File
@@ -129,6 +129,8 @@ program
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
const LINKED_CASES_FILE = dataPath('linked-cases.json');
/** Graceful shutdown budget before the process force-exits (see the SIGTERM handler). */
const SHUTDOWN_FORCE_EXIT_MS = 10_000;
/**
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
@@ -1002,6 +1004,14 @@ webCmd.action(async (options) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
// A hung stop() must not keep the process alive: the listener is already
// closed by then, and a KeepAlive LaunchDaemon only respawns the server once
// it EXITS (systemd would SIGKILL after TimeoutStopSec; launchd does not).
// Seen after a self-update on macOS: port closed, process alive, service down.
setTimeout(() => {
console.error(palette.err(`Shutdown did not finish in ${SHUTDOWN_FORCE_EXIT_MS / 1000}s, forcing exit`));
process.exit(1);
}, SHUTDOWN_FORCE_EXIT_MS).unref();
try {
await server.stop();
} catch (err) {
+115
View File
@@ -0,0 +1,115 @@
/**
* @fileoverview Write side of the CLI registry (docs/cli-enable-disable-plan.md, Phases 3/5).
*
* Kept deliberately SEPARATE from `registry.ts`, whose reading path does no writes on import
* (`schemas.ts` imports it, transitively). Only `cli-registry-routes.ts` imports this module,
* so that property still holds for every OTHER importer of the registry.
*
* Every mutation goes through `mutateRegistryFile()`, which does three things the #476 review
* found missing:
*
* - **Serialized.** Mutations run one at a time on a single promise chain, and each one
* reads, changes, writes and reloads before the next starts. Unserialized read-modify-write
* lost toggles when three `PUT /api/clis/:id` calls ran in parallel.
* - **Refuses a file it must not trust.** The reader ignores a `clis.json` with any
* group/world permission bit and quarantines one that does not parse. The writer used to
* treat both as "start fresh", so one Settings click replaced a hand-edited file with a
* one-key file, or rewrote a refused file as 0600 and so trusted it. It now starts fresh
* ONLY on ENOENT and otherwise throws `RegistryWriteRefusedError`, leaving the file alone.
* - **Unique temp file.** Every write gets its own tmp name before the rename, so two writes
* can never rename each other's temp file away (the ENOENT-on-rename 500s).
*
* Same tmp+rename+0600 shape as `custom-model-hosts.ts`. The file is hand-editable, so a
* write must never leave it half-written, and 0600 is the mode `isUnsafePermissions()`
* requires on the next read.
*/
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { isUnsafePermissions, registryFilePath, reloadCliRegistry } from './registry.js';
import type { CliRegistryFile } from './types.js';
/** A write refused because the existing `clis.json` must not be overwritten. The message is user-facing. */
export class RegistryWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'RegistryWriteRefusedError';
}
}
/**
* Read the raw override file for mutation. Only a MISSING file starts fresh. A file with
* unsafe permissions, one that cannot be read, or one that does not parse is refused rather
* than overwritten, because the user's hand-edit is worth more than one toggle.
*/
export async function readRegistryFileForWrite(): Promise<CliRegistryFile> {
const path = registryFilePath();
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { schemaVersion: 1, clis: {} };
throw new RegistryWriteRefusedError(`Cannot read ${path} (${(err as Error).message}); not changing it.`);
}
if (isUnsafePermissions(path)) {
throw new RegistryWriteRefusedError(
`${path} has group/world permission bits, so Codeman ignores it. Run \`chmod 600 ${path}\` and check its contents before changing CLIs here.`
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new RegistryWriteRefusedError(
`${path} is not valid JSON (${(err as Error).message}). Fix or remove it before changing CLIs here.`
);
}
const clis = (parsed as { clis?: unknown } | null)?.clis;
if (typeof parsed !== 'object' || parsed === null || typeof clis !== 'object' || clis === null) {
throw new RegistryWriteRefusedError(`${path} has no "clis" object. Fix or remove it before changing CLIs here.`);
}
return parsed as CliRegistryFile;
}
export async function writeRegistryFile(file: CliRegistryFile): Promise<void> {
const target = registryFilePath();
const dir = dirname(target);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Run one registry mutation. The chain holds exactly one at a time: `fn` receives the
* current file and returns `{ file, result }`. If `file` is set it is written and the
* registry reloaded before the next mutation starts; if not, nothing is written, which is
* how a validation failure returns early. Checks made inside `fn` (does this id exist,
* is it a duplicate) therefore see every earlier mutation's result.
*
* A failed mutation rejects its own caller only. The chain keeps going.
*/
export function mutateRegistryFile<T>(
fn: (file: CliRegistryFile) => Promise<{ file?: CliRegistryFile; result: T }> | { file?: CliRegistryFile; result: T }
): Promise<T> {
const run = mutationChain.then(async () => {
const current = await readRegistryFileForWrite();
const { file, result } = await fn(current);
if (file) {
await writeRegistryFile(file);
reloadCliRegistry();
}
return result;
});
mutationChain = run.catch(() => {});
return run;
}
+14 -1
View File
@@ -48,6 +48,16 @@ function filePath(): string {
return dataPath('clis.json');
}
/**
* The resolved path of `~/.codeman/clis.json`, exported for the write API
* (`cli-registry-writer.ts`, docs/cli-enable-disable-plan.md Phases 3/5) so both the read and
* write sides resolve the SAME path through the SAME instance-scoped helper — never a second
* `dataPath('clis.json')` call that could drift from this one under a future `dataPath()` change.
*/
export function registryFilePath(): string {
return filePath();
}
/**
* Keys that must never be merged out of a hand-editable JSON file.
*
@@ -90,8 +100,11 @@ export interface LoadResult {
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
* this check cannot see and does not attempt to.
*
* Exported for `registry-writer.ts`, which must refuse the same files: rewriting a refused
* file as 0600 would silently turn it into trusted config.
*/
function isUnsafePermissions(path: string): boolean {
export function isUnsafePermissions(path: string): boolean {
if (process.platform === 'win32') return false;
try {
const mode = statSync(path).mode & 0o777;
+47
View File
@@ -294,6 +294,23 @@ const capabilitiesSchema = z
effort: z.boolean(),
agentSkillInjection: z.boolean(),
statusLineTelemetry: z.boolean(),
// How many columns this CLI indents its transcript body by, so a copy can take
// that much off the clipboard. Bounded, because it is the whole strip: a copy
// never removes more than this, nor more than every selected line shares.
//
// ⚠ DECLARED, not measured off the pane, and two measured attempts are why.
// Asking whether the pane painted spaces across the unused part of each row
// separates a TUI from a shell perfectly where it fires and never
// over-stripped, but it is a function of pane WIDTH: that padding exists
// only while a rendered line stops short of the CLI's own layout width, and
// Claude Code's prose wraps to fill it — the share of padded rows on one
// live transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
// columns, so the strip did nothing at any ordinary size. Taking the
// narrowest indent on screen instead fires everywhere and over-strips, since
// a file listing inside the transcript can be the narrowest thing on it.
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -308,8 +325,38 @@ const capabilitiesSchema = z
(src) => compileVersionRegex(src) !== null,
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
),
// Same guard, same reasons: this one runs over the foot of a pane capture every
// time a session settles, and ~/.codeman/clis.json can set it.
watchingLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'watchingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Bounded hard: this is how far up the screen a config file may push the search,
// and every row it adds is one more row the agent itself may be able to write.
watchingLines: z.number().int().min(1).max(8).optional(),
// Same guard again: tested against a pane row every time a session settles.
awaitingLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
// time for the same reason `privilegedParams[].param` is checked against the params
// the entry declares: the failure is otherwise silent and looks like a feature that
// simply never fires.
.refine(
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
+102 -14
View File
@@ -75,11 +75,24 @@ function agentDefaults(): Pick<
};
}
// `accent` on every entry below (except SHELL, which the frontend renders no
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
// representative hex each entry's own multi-stop gradient resolves around.
// Corrected 2026-09-21 after PR #458's review found several were simply wrong
// (e.g. claude was registered as Anthropic's brand orange, `#d97757`, but the
// button renders blue): `docs/cli-registry.md`'s own "transcribed, not
// authoritative, re-measure before wiring one up" warning for this
// DECLARED-FOR-LATER field, taken literally. The one exception is GEMINI, whose
// run-button border (#60a5fa) is the only one that disagrees with its own tab badge
// and run-mode dot (#8ab4f8); it takes the badge colour, so every accent names the
// same hex the frontend uses as that CLI's flat identity. This is a data-accuracy fix only —
// `accent` still has no reader, so nothing rendered changes because of it.
const CLAUDE: CliEntry = {
id: 'claude' as CliEntry['id'],
label: 'Claude',
label: 'Claude Code',
shortBadge: 'CC',
accent: '#d97757',
accent: '#3b82f6',
enabled: true,
stock: true,
order: 0,
@@ -200,12 +213,48 @@ const CLAUDE: CliEntry = {
},
capabilities: {
external: false,
// Claude indents its transcript body two columns and puts its own ●/✻/❯ markers
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
workDetect: {
promptGlyph: '❯',
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
// Claude prints what it started in the background on the footer row beneath its
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are
// the CLI's own words for each kind of background task, and group 1 is the one
// Codeman badges the session with. Verified against a live 2.1.278 pane on
// 2026-09-21.
// ⚠️ Two things keep an agent from writing its own label here, and both matter.
// The footer is the LAST row, so the default one-row window (`WATCHING_TAIL_LINES`)
// holds nothing but Ink's own chrome — in particular it leaves out the status line
// directly above, whose content comes from a `statusLine` command a bypassed
// session can write into its own `.claude/settings.json`. And the leading `·` keeps
// the match on the footer's own item list rather than on any text that happens to
// carry a count. A footer that ever drew the chip as its only item would report no
// watching rather than open that door. See `watchingLabel()` in
// `session-activity.ts`.
// ⚠️ An Artifact comment monitor is the one chip that waits on the user. The agent
// has published a page and hears nothing until somebody comments on it, so the
// lookahead refuses the whole row while that chip is on it, whatever else is
// running beside it. The `^` is what makes the lookahead judge the row once:
// without it the engine retries from each later position, and a start past the
// chip reports the shell beside it. The lookahead keys on "Artifact" alone, so a
// footer cut off mid-chip (`· 1 Artifact…`, `· 1 Artifact comm…`) is still refused;
// no other chip on this row says "Artifact". Counting the chip as watching kept the
// idle alert quiet for a session that was waiting for a human.
watchingLine: String.raw`^(?!.*Artifact).*?·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?))`,
// When a turn ends while background agents or an ultracode workflow are still
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
// and never redrawn, which is why only the newest row above the composer counts.
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
},
requiresMux: false,
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
@@ -214,6 +263,8 @@ const CLAUDE: CliEntry = {
transcript: 'claude-jsonl',
altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
keyboardAccessory: 'agent',
privilegedCommandGate: false,
@@ -368,7 +419,7 @@ const OPENCODE: CliEntry = {
id: 'opencode' as CliEntry['id'],
label: 'OpenCode',
shortBadge: 'OC',
accent: '#f59e0b',
accent: '#10b981',
enabled: true,
stock: true,
order: 10,
@@ -454,7 +505,7 @@ const CODEX: CliEntry = {
id: 'codex' as CliEntry['id'],
label: 'Codex',
shortBadge: 'CX',
accent: '#6b7fd7',
accent: '#a855f7',
enabled: true,
stock: true,
order: 20,
@@ -518,7 +569,43 @@ const CODEX: CliEntry = {
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
// alone separates a running turn from an idle one.
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
// Codex pins a row of its own while a background terminal it started is still
// running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike
// Claude's footer chip that row sits ABOVE the composer, which puts it third from the
// bottom once the status line and the composer are counted, hence `watchingLines`.
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
// the terminal starts, follows the composer down as the conversation grows, and is
// gone after `/stop`.
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
// layout rather than its pattern. The third row from the bottom is the chip only
// while a terminal runs; with none running it is the last row of the transcript,
// which the agent writes. Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
// A sub-agent started without waiting outlives the turn just as a terminal does —
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
// nothing at all for it: the last rows are the composer and the status line, and
// `Sub-agents running` lives in the on-demand `/subagents` panel, not above the
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
// match until codex pins one.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 3,
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
// 160, 198, 235 and 282 columns the indents were 0, 2, 4, 6 and 8 at every one,
// never 1, so the width is not a function of the pane.
transcriptGutter: 2,
transcript: 'codex-rollout',
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
@@ -565,7 +652,8 @@ const GEMINI: CliEntry = {
id: 'gemini' as CliEntry['id'],
label: 'Gemini',
shortBadge: 'GM',
accent: '#4285f4',
// The tab badge / run-mode-dot colour, not the run-button border (see the note above CLAUDE).
accent: '#8ab4f8',
enabled: true,
stock: true,
order: 30,
@@ -657,7 +745,7 @@ const ANTIGRAVITY: CliEntry = {
id: 'antigravity' as CliEntry['id'],
label: 'Antigravity',
shortBadge: 'AG',
accent: '#8b5cf6',
accent: '#22d3ee',
enabled: true,
stock: true,
order: 40,
@@ -727,7 +815,7 @@ const PI: CliEntry = {
id: 'pi' as CliEntry['id'],
label: 'Pi',
shortBadge: 'PI',
accent: '#10b981',
accent: '#f472b6',
enabled: true,
stock: true,
order: 50,
@@ -851,10 +939,10 @@ const GROK: CliEntry = {
shortBadge: 'GK',
// Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab
// badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's
// `accent` is a single hex, so this is the closest single value (the run-mode-dot colour,
// zinc-400). Nothing reads `accent` yet — the frontend is untouched in this change and
// keeps its own hand-authored CSS; the field is here so the entry is complete.
accent: '#a1a1aa',
// `accent` is a single hex, so this is the closest single value (zinc-300, the run-button
// border and tab-badge colour). Nothing reads `accent` yet: the frontend keeps its own
// hand-authored CSS; the field is here so the entry is complete.
accent: '#d4d4d8',
enabled: true,
stock: true,
order: 70,
@@ -986,7 +1074,7 @@ const DEEPSEEK: CliEntry = {
id: 'deepseek' as CliEntry['id'],
label: 'DeepSeek',
shortBadge: 'DS',
accent: '#4d6bfe',
accent: '#7c93ff',
enabled: true,
stock: true,
order: 80,
@@ -1151,7 +1239,7 @@ const OMP: CliEntry = {
id: 'omp' as CliEntry['id'],
label: 'OMP',
shortBadge: 'OM',
accent: '#7c9cf5',
accent: '#818cf8',
enabled: true,
stock: true,
order: 90,
+67 -5
View File
@@ -344,7 +344,61 @@ export interface CliCapabilities {
promptGlyph: string;
/** Source of a regex matching the status line this CLI draws while a turn runs. */
workingLine: string;
/**
* Source of a regex matching the row this CLI draws while work it started in the
* background is still running, e.g. Claude's `· 1 monitor ·` footer chip or Codex's
* `1 background terminal running · /ps to view`. Capture group 1 is the label Codeman
* shows, and the whole match stands in when the pattern declares no group. A CLI that
* omits this reports no background work, which is what every CLI did before the field
* existed.
*/
watchingLine?: string;
/**
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
* its own above the composer, which puts it third from the bottom, so it declares
* more. Keep each number as small as that CLI's layout allows: every extra row is
* another row an agent might be able to write, and the label is what silences an
* alert. See `watchingLabel()` in `session-activity.ts`.
*/
watchingLines?: number;
/**
* Source of a regex matching the row this CLI closes a turn with when it ended that
* turn to WAIT for workers it started and will resume on its own once they finish,
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`. A pane showing it counts
* as working, not idle: nothing is being asked of the user, and the next turn starts
* without them.
*
* Unlike `workingLine` this is never searched across the pane. The CLI prints the row
* once and never updates it, so the copy from an earlier turn is still on screen after
* the workers are done. Only the newest transcript row directly above the composer is
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
*/
awaitingLine?: string;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
* pane can drop that much and paste flush. Claude Code indents two and puts its own
* markers in those columns.
*
* ⚠ DECLARED rather than measured off the pane, and two measured attempts are why.
* Asking whether the pane painted real spaces across the unused part of each row
* separates a TUI from a shell perfectly where it fires and never over-stripped; it
* is also a function of pane WIDTH, because that padding exists only while a
* rendered line stops short of the CLI's own layout width and Claude Code's prose
* wraps to fill it. On one live transcript the share of padded rows ran 44%, 6%, 6%,
* 7% and 87% at 123, 160, 198, 235 and 298 columns, so at any ordinary window size
* the strip silently did nothing. Taking the narrowest indent on the surrounding
* rows instead fires at every width and over-strips on roughly 1% of selections,
* because a file listing inside the transcript can be the narrowest thing on screen.
*
* A declared width can do neither. The strip is the lesser of this and what every
* selected line shares, so a block can only ever shift as a unit, and it can never
* shift further than the CLI itself says its gutter is.
*
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
@@ -629,12 +683,14 @@ export interface CliOverlays {
/**
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
*
* `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
* `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
* behaviour, and the frontend is deliberately untouched by the change that introduced this
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
* behaviour, and most of the frontend is deliberately untouched by the change that introduced
* this registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
* being verified (a mobile/browser suite the CI gate cannot see).
* being verified (a mobile/browser suite the CI gate cannot see). `shortBadge` graduated out of
* this list (docs/cli-enable-disable-plan.md, Phase 2): `GET /api/clis` reads it for the
* CLI-management Settings list.
*
* They are declared now because each entry should describe its CLI completely, and because
* transcribing them while the hand-written source is still on screen is when the values are
@@ -651,7 +707,13 @@ export interface CliEntry {
label: string;
/** Two-ish character tab badge, e.g. 'OC'. */
shortBadge: string;
/** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */
/**
* Single hex colour, measured from the CLI's actual `.btn-toolbar.btn-run.mode-<id>`
* gradient in styles.css (see stock.ts's comment above `CLAUDE` for the exact
* methodology). DECLARED-FOR-LATER (above) — no code reads this yet; styles.css's
* gradients are still hand-authored per id, not derived from this field via any
* CSS custom property. There is no `--cli-accent` variable in the codebase.
*/
accent: string;
enabled: boolean;
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
+3 -1
View File
@@ -69,7 +69,9 @@ const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
* shown to the user and claude's does not follow the pattern.
*/
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
claude: { usedBy: ['Claude Code sessions (default backend)'] },
// The label override keeps the doctor row's historical "Claude CLI" spelling now that
// the registry label is the product name, "Claude Code".
claude: { label: 'Claude CLI', usedBy: ['Claude Code sessions (default backend)'] },
opencode: { usedBy: ['OpenCode sessions'] },
codex: { usedBy: ['Codex sessions'] },
gemini: { usedBy: ['Gemini sessions'] },
+8
View File
@@ -99,3 +99,11 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Gap between a paste-mode cron prompt's text and its Enter (ms). The two must be
* separate writes: Claude Code takes a raw `<text>\r` burst of about a hundred
* characters as a paste and turns its `\r` into a newline. A separate `\r` 80 ms
* after the text was measured to submit; this leaves room for a longer prompt.
*/
export const CRON_PASTE_ENTER_DELAY_MS = 300;
+38 -9
View File
@@ -20,7 +20,7 @@ import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import { CRON_PASTE_ENTER_DELAY_MS, CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import {
DEFAULT_BLOCKED_TREES,
isBlockedAttachmentPath,
@@ -100,6 +100,41 @@ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TR
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
const HAS_NEWLINE = /[\r\n]/;
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
type CronPromptTarget = Pick<Session, 'write' | 'writeViaMux' | 'verifySubmitted'>;
/**
* Send a cron job's (single-line) prompt into its session and press Enter.
*
* `typed` goes through the mux: the text is typed, Enter is its own key, and the
* session re-presses it while the prompt is still on the composer.
*
* `paste` writes the text straight into the PTY, and must send its Enter as a
* SEPARATE write. It used to send `<text>\r` in one piece, and Claude Code (measured
* on 2.1.283) takes a burst of about a hundred characters as a paste, so the `\r`
* landed as a newline and the prompt sat unsent while the run reported
* `prompt_sent`. The Enter goes down the same PTY as the text, so it cannot overtake
* it, and the same composer check then covers a CLI that was not taking Enter yet.
*
* @returns false when the session had no PTY or mux to write to
*/
export async function deliverCronPrompt(
target: CronPromptTarget,
prompt: string,
inputMode: CronJob['inputMode'],
wait: (ms: number) => Promise<void> = delay
): Promise<boolean> {
if (inputMode !== 'paste') {
return target.writeViaMux(prompt.endsWith('\r') ? prompt : `${prompt}\r`);
}
const text = prompt.replace(/[\r\n]+$/, '');
if (!target.write(text)) return false;
await wait(CRON_PASTE_ENTER_DELAY_MS);
if (!target.write('\r')) return false;
target.verifySubmitted(text);
return true;
}
/** Order-insensitive equality for the weekly-days arrays. */
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
const x = [...(a ?? [])].sort((p, q) => p - q);
@@ -623,15 +658,9 @@ export class CronService {
const s = this.deps.sessions.get(sessionId);
if (!s) return;
try {
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
let delivered = true;
if (job.inputMode === 'paste') {
s.write(payload);
} else {
delivered = await s.writeViaMux(payload);
}
const delivered = await deliverCronPrompt(s, prompt, job.inputMode);
if (!delivered) {
this.failRun(job, run, 'Failed to send prompt: mux write failed');
this.failRun(job, run, 'Failed to send prompt: the session could not be written to');
return;
}
run.status = 'prompt_sent';
+105 -5
View File
@@ -606,9 +606,65 @@ export function agentImageNpmPackages(): string[] {
return packages;
}
/** The `--build-arg` pairs the agent image takes. */
export function agentImageBuildArgPairs(): Array<[string, string]> {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]];
/**
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
* ⚠️ Mirrors `GIT_HOST_CLI_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
*/
export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* Environment variable → Dockerfile ARG for the image's system Git identity.
* ⚠️ Mirrors `GIT_IDENTITY_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
*/
export const GIT_IDENTITY_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
];
/**
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
* as before these existed; anything other than 0/1 is refused rather than guessed at.
*/
export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
const pairs: Array<[string, string]> = [];
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
const value = env[envName];
if (value === undefined || value === '') continue;
if (value !== '0' && value !== '1') {
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
}
pairs.push([argName, value]);
}
return pairs;
}
/**
* The `--build-arg` pairs for a configured Git identity. An absent pair leaves
* Git unconfigured, preserving existing deployments; a partial pair is refused.
* ⚠️ Mirrors `gitIdentityBuildArgPairs()` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
*/
export function gitIdentityBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? ''] as [string, string]);
const configured = pairs.filter(([, value]) => value !== '');
if (configured.length === 0) return [];
if (configured.length !== pairs.length) {
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
throw new Error(`${names} must both be set when configuring Git identity`);
}
return pairs;
}
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
return [
['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')],
...gitHostCliBuildArgPairs(env),
...gitIdentityBuildArgPairs(env),
];
}
// ========== Credential mount resolution (IO) ==========
@@ -785,6 +841,14 @@ interface CredStorePolicy {
seedFiles?: string[];
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
seedWhole?: boolean;
/**
* Seed this store ONLY when this environment variable is exactly `1`, read when
* the container is created. For credentials that belong to an opt-in tool rather
* than to an agent CLI every case already trusts: they are not inert just because
* the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is
* usable by anything in the container, and the agent in it is prompt-injectable).
*/
enabledByEnv?: string;
}
const CRED_STORES: CredStorePolicy[] = [
@@ -829,6 +893,31 @@ const CRED_STORES: CredStorePolicy[] = [
},
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
// GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the
// Docker server image, a headless Linux host), `config.yml` the preferences. An
// agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials
// through `gh`, so this seed is what lets an agent clone/push a private repo. A
// token that lives in a desktop keyring is not in `hosts.yml` and does not carry
// in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch
// that builds gh into the agent image is on, never merely because the file exists.
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' },
// Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`,
// `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is
// needed to authenticate; the agent image carries its own extensions outside HOME.
// `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so
// this carries a sign-in from the Docker server image or a Linux host.
// OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account.
{
rel: '.azure',
enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ',
seedFiles: [
'azureProfile.json',
'msal_token_cache.json',
'service_principal_entries.json',
'clouds.config',
'config',
],
},
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
@@ -853,10 +942,14 @@ const CRED_STORES: CredStorePolicy[] = [
* session state back into the host). Every path is existsSync-gated (on most hosts
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
*/
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
export function resolveDockerCredentialArtifacts(
home: string = homedir(),
env: NodeJS.ProcessEnv = process.env
): DockerClaudeArtifacts {
const mounts: DockerMount[] = [];
const seedCopies: DockerSeedCopy[] = [];
for (const store of CRED_STORES) {
if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue;
const hostBase = join(home, store.rel);
if (!existsSync(hostBase)) continue;
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
@@ -1136,10 +1229,17 @@ function buildAgentImage(
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
});
}
let buildArgPairs: Array<[string, string]>;
try {
buildArgPairs = agentImageBuildArgPairs();
} catch (err) {
// A malformed CODEMAN_AGENT_IMAGE_* value: report it like any other build failure.
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
}
const argv = dockerEngineArgv(docker);
const args = [
...argv.slice(1),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, buildArgPairs),
];
return new Promise<EnsureImageResult>((resolve) => {
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
+30 -6
View File
@@ -195,6 +195,8 @@ export interface CloneOptions {
/** `--depth 1`: history-less but much faster on large repos. */
shallow?: boolean;
timeoutMs?: number;
/** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */
withoutCredentialHelpers?: boolean;
}
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
@@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean {
// ─── Pure: argv + env ────────────────────────────────────────────────────────
/**
* Global git options that empty the credential-helper list for one run.
*
* Every Codeman user in multi-user mode runs git as the SAME OS account, so a
* helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a
* user's own `gh auth setup-git`) would read private repositories on the
* signed-in admin's behalf for anyone who can reach Clone Repo. An empty
* `credential.helper` resets the helper list, and a command-line `-c` is read
* last, so it also drops the URL-scoped `credential.<url>.helper` entries the
* image configures (verified against a real private repo: refs with the helper,
* `could not read Username` with it cleared). Public repositories are
* unaffected. It must precede the subcommand.
*/
export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper='];
/**
* argv for the clone. `--` separates flags from operands so neither the
* repository nor the destination can ever be read as an option.
*/
export function buildCloneArgs(opts: CloneOptions): string[] {
const args = ['clone'];
const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone'];
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
if (opts.shallow) args.push('--depth', '1');
@@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] {
}
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
export function buildLsRemoteArgs(repository: string): string[] {
return ['ls-remote', '--symref', '--', repository];
export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] {
return [
...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []),
'ls-remote',
'--symref',
'--',
repository,
];
}
/**
@@ -579,7 +602,7 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
return {
code: 'AUTH_REQUIRED',
message:
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
"That repository needs authentication. Codeman never asks for credentials, so sign this server's git in first (for example `gh auth login` or `az login` from a shell session; the Docker image can include both, see docker/README.md), or clone it outside Codeman and add it with Link Existing.",
stderr: clean,
};
}
@@ -790,7 +813,8 @@ export function isGitAvailable(): boolean {
*/
export async function probeGitRemote(
repository: string,
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS,
opts: { withoutCredentialHelpers?: boolean } = {}
): Promise<GitRemoteProbe> {
if (!isGitAvailable()) {
return {
@@ -800,7 +824,7 @@ export async function probeGitRemote(
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
};
}
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES);
if (run.code !== 0 || run.spawnError) {
return {
reachable: false,
+64 -1
View File
@@ -24,6 +24,7 @@ import type {
OmpConfig,
SessionRemote,
SessionDocker,
PaneExit,
} from './types.js';
/**
@@ -56,6 +57,23 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
* entry either, which means a remote or docker session rediscovered this way
* arrives with no `remote`/`docker` metadata and looks local. Anything that
* would be WRONG about such a session rather than merely vague must fail
* closed on this flag.
*
* ⚠ It is PERMANENT, not merely true for the boot that rediscovered the
* session: `saveSessions()` serializes the whole record to
* `mux-sessions.json` and `loadSessions()` restores it, so a genuinely local
* session rediscovered once stays opted out of everything keyed on this for
* the life of that record. That is the safe direction to fail, and it costs
* only the guess Codeman is declining to make.
*/
discovered?: boolean;
}
/**
@@ -72,6 +90,13 @@ export interface CreateSessionOptions {
workingDir: string;
mode: SessionMode;
name?: string;
/**
* 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`).
*/
cliName?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
@@ -90,6 +115,8 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
advisorModel?: string;
/** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -105,8 +132,15 @@ export interface RespawnPaneOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
/** Session display name (tab name). */
name?: string;
/**
* 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`).
*/
cliName?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
@@ -132,6 +166,8 @@ export interface RespawnPaneOptions {
unsetEnvKeys?: string[];
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
advisorModel?: string;
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -180,6 +216,7 @@ export interface PaneCaptureOptions {
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
@@ -294,6 +331,32 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
isPaneDead(muxName: string): boolean;
/**
* What the last pane read saw of this session's agent, or `undefined` for
* UNKNOWN (Ark0N/Codeman#446). Unlike `isPaneDead()` this costs nothing: it
* reads a map the batched watcher fills, so it answers no fresher than that
* watcher's interval and the three synchronous `isPaneDead()` callers still
* need their own probe. See {@link PaneExit}.
*/
getPaneExit?(muxName: string): PaneExit | undefined;
/**
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
* reports, or 0 when it reports none. The exited-agent sweep closes a session
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
* and a multiplexer without this method never has a session closed by it.
*/
getPaneExitReadCount?(muxName: string): number;
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
clearPaneExit?(muxName: string): void;
/** Start polling every pane on the socket for an exited agent. */
startPaneExitWatcher?(intervalMs?: number): void;
/** Stop the pane-exit watcher. */
stopPaneExitWatcher?(): void;
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
+99
View File
@@ -0,0 +1,99 @@
/**
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
*
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
* CLI while the pane, the tmux session and the `tmux attach-session` process
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
* module decides when such a session is closed, the way the X button closes
* it, so finished sessions stop piling up on the board.
*
* The rule closes a session only on a POSITIVE observation of a clean exit:
*
* - The exit status must be an explicit numeric 0 with no signal. An absent
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
* status nor a signal, so reading absence as clean would sweep an agent the
* OOM killer took. A non-zero status or any signal keeps the row, marked with
* the exit, as the crash evidence #210 was filed to keep.
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
* have agreed on that exit. A failed, empty or skipped read counts for
* nothing, because unknown never closes anything.
* - No start, attach or relaunch may be in flight for the session. The
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
* pane on purpose, and for a few seconds that pane still reads as dead.
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
* the last start, attach or relaunch finished. A CLI that prints a startup
* error ("not logged in", a bad profile, a config error) and exits 0 would
* otherwise lose its tab, and the error with it, seconds after launch. Its
* row stays, marked `exited (0)`, for the user to read and close.
*
* Scoping to local mux-backed sessions happens before this rule runs:
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
* docker and discovered sessions, so their `paneExit` never reaches here.
*
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
*/
import type { PaneExit } from './types/index.js';
/**
* How many authoritative pane reads must agree on a clean exit before the
* session is closed. At the watcher's 2 s cadence two reads mean a finished
* session disappears within about four seconds of its agent exiting.
*/
export const CLEAN_EXIT_CONFIRMING_READS = 2;
/**
* How long a pane must have been up before a clean exit closes its session.
* An exit sooner than this after the last pane start is read as a startup
* failure rather than a user ending the agent, and the row is kept.
*/
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
/** The lifecycle-log reason recorded when the sweep closes a session. */
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
/**
* Is this exit a clean one? True only for an explicit numeric status of 0 with
* no signal reported.
*
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
* clean". An absent status is how a signal death presents on tmux 3.2a, and
* that shortcut would close crashed agents with nothing failing to warn you.
*/
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
if (!exit) return false;
if (exit.signal !== undefined) return false;
return exit.status === 0;
}
/** Everything the sweep needs to know about one session. */
export interface CleanExitSweepCandidate {
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
paneExit: PaneExit | undefined;
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
confirmingReads: number;
/** A start, attach or relaunch is running for this session's pane. */
paneLifecycleInFlight: boolean;
/** The session is already being closed or detached. */
closing: boolean;
/**
* When the last start, attach or relaunch of this pane finished
* (`Session.paneStartedAt`), or 0 when none has run in this process.
*/
paneStartedAt: number;
}
/** Should the sweep close this session now? See the file overview for the rule. */
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
if (candidate.closing) return false;
if (candidate.paneLifecycleInFlight) return false;
if (!isCleanPaneExit(candidate.paneExit)) return false;
// `at` is when this server first read the pane dead, so an exit during the
// start itself lands BEFORE `paneStartedAt` and is kept too.
if (
candidate.paneStartedAt > 0 &&
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
) {
return false;
}
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
}
+27 -14
View File
@@ -19,19 +19,22 @@
* touching its status, so a pinned session a reboot killed still reads `idle` or
* `busy` and stays eligible.
*
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
* `tmux attach-session` process, which stays alive throughout — so no exit
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
* notice the dead pane; until a record can say the agent is gone, this pass will
* offer those sessions back, and the user dismisses or closes them.
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
* with status 0 through `cleanupSession()`, which leaves the durable record
* described above. This module also refuses a record whose persisted
* `paneExit` is a clean exit, which covers a session that exited moments
* before the power went, before the sweep reached it. A crashed agent's record
* stays eligible, like the row the sweep leaves on the board for it.
*
* The `pid` check below is therefore NOT that rule. It refuses a record whose
* attach process was already gone, which is a session that never started or
* whose pane died outright.
* The `pid` check below is NOT that rule. It refuses a record whose attach
* process was already gone, which is a session that never started or whose
* pane died outright.
*
* @dependencies types (SessionState), config/cli-registry
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
@@ -41,6 +44,7 @@
import type { SessionState } from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { isCleanPaneExit } from './pane-exit-sweep.js';
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
@@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string {
/**
* Why one session was passed over. Reported for logging and shown to the user.
*
* The first seven are decided before anything is built. `capacity-reached` and
* All but the last two are decided before anything is built. `capacity-reached` and
* `rebuild-failed` can only happen once a click is spending the plan, and they
* are the two the banner must not confuse with a missing workspace: one means
* "try again after closing something", the other means the CLI would not start.
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
| 'no-persisted-record'
| 'intentionally-ended'
| 'not-running'
| 'agent-exited'
| 'respawn-blocked'
| 'remote-or-docker'
| 'unsupported-mode'
@@ -191,7 +196,8 @@ export function planRebootRestore(
//
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
// module header: that leaves the pid in place, because the pid is the tmux
// attach process and `remain-on-exit` keeps it alive.
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
// below catches it instead.
//
// Conservative on purpose. A session that somehow persisted no pid while
// genuinely running is not offered, and its conversation stays reachable
@@ -200,6 +206,13 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'not-running' });
continue;
}
if (isCleanPaneExit(state.paneExit)) {
// The user ended the agent, and the clean-exit sweep would have closed the
// session had the power not gone first (Ark0N/Codeman#446). The same
// explicit-0 rule applies: an absent status is unknown, not clean.
skipped.push({ sessionId, reason: 'agent-exited' });
continue;
}
if (state.respawnBlocked === true) {
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
skipped.push({ sessionId, reason: 'respawn-blocked' });
+123
View File
@@ -21,6 +21,8 @@
* in 12/12 windows and the four idle ones in 0/12.
*/
import { stripAnsi } from './utils/regex-patterns.js';
/**
* A gap longer than this ends a run of continuous output. Claude repaints at
* least once a second while working, so this leaves generous headroom.
@@ -91,3 +93,124 @@ export function isSustainedActivity(streak: ActivityStreak | null, streakMs: num
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
return now - lastActivityAt >= silenceMs;
}
/**
* How many rows at the foot of a pane capture may hold the background-work row, for a
* CLI that declares no number of its own (`capabilities.workDetect.watchingLines`).
*
* One, because the tightest window is the right default and Claude Code needs no more:
* it draws its chip on the LAST row of the screen. Blank rows are dropped before the
* window is taken, so a trailing blank costs nothing, and a CLI that ever prints a row
* BELOW its chip loses the badge rather than gaining a hole.
*
* ⚠️ The size of this window is a trust boundary, not a tidiness measure, and the row
* it excludes first is the one that taught us so: Claude's status line sits directly
* above the footer, its content comes from a `statusLine` command, and a session running
* with permissions bypassed can write that command into `.claude/settings.json` in its
* own workspace. A window of two therefore let an agent print `· 1 monitor ·` onto a row
* of its own and silence its own idle alert. Every row added here is another row
* somebody may be able to write, so widen this only for a CLI whose layout forces it,
* and never to a whole-pane search.
*/
export const WATCHING_TAIL_LINES = 1;
/** Longest label a badge will carry. A footer chip is a handful of words. */
export const MAX_WATCHING_LABEL_CHARS = 40;
/**
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
*
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
* started is still going, which is exactly the case where the agent has ended its turn
* without wanting anything from the user. `pattern` comes from the CLI's own registry
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
* declares one, and the whole match stands in when it does not.
*
* Each candidate row is tested on its own, bottom row first, so a pattern can anchor
* itself with `^` or `$` against a single row rather than against a joined block. Blank
* rows are dropped before the window is taken, because a CLI that leaves a blank line
* between its chrome rows would otherwise spend the window on nothing. The answer is
* stripped of ANSI and capped, because it ends up on a badge and in an approval card.
*
* @param tailLines how many non-blank rows from the bottom to look at, defaulting to
* `WATCHING_TAIL_LINES`; a CLI declares its own when its row is not the last one
* @returns the label, or null when the pane shows no background work
*/
export function watchingLabel(
paneText: string | null | undefined,
pattern: RegExp,
tailLines: number = WATCHING_TAIL_LINES
): string | null {
if (!paneText) return null;
const lines = stripAnsi(paneText)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line !== '');
for (const line of lines.slice(-Math.max(1, tailLines)).reverse()) {
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
// caller reaching in from a test or a config reload might, and a stale lastIndex
// would make the same screen match every other call.
pattern.lastIndex = 0;
const match = pattern.exec(line);
if (!match) continue;
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
if (label) return label;
}
return null;
}
/**
* How many rows above the composer the turn's closing row may sit. Between the two Claude
* draws only its composer border and, sometimes, a right-aligned hint
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
* transcript to find an old turn's row.
*/
export const AWAITING_SEARCH_ROWS = 6;
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
const COMPOSER_FRAME_ROW = /^[─-╿]/;
/**
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
*
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
* nothing is being asked of the user: the CLI resumes by itself when the workers report
* back. That is why a session in this state counts as working.
*
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
* updates it, so after the workers finish the same words are still on screen above the
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
* long as they stay visible. Only the newest transcript row counts: the walk starts at
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
* its own there, so the stale copy is never the one tested.
*
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
* @returns false when the screen shows no composer, which is no evidence either way
*/
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
if (!paneText) return false;
const rows = stripAnsi(paneText)
.split('\n')
.map((row) => row.trimEnd());
let composer = -1;
for (let i = rows.length - 1; i >= 0; i--) {
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
composer = i;
break;
}
}
if (composer < 0) return false;
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
const row = rows[i];
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
pattern.lastIndex = 0;
return pattern.test(row);
}
return false;
}
+33 -3
View File
@@ -9,7 +9,7 @@
*/
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { isAdvisorModel, isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
@@ -54,6 +54,25 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
}
/**
* The `--settings` keys that switch on Claude Code's advisor tool for one session: a
* stronger model the main model consults at decision points (code.claude.com/docs/en/advisor).
* Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally.
*
* ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at
* launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an
* advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which
* would leave a dead pane on every spawn and respawn. The settings key degrades instead: the
* CLI simply does not attach an advisor it cannot use. It is a SOFT default either way:
* `/advisor` still switches or turns it off inside the running session.
*
* ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into
* the same JSON object as ultracode and the statusLine exporter, never rendered on its own.
*/
export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } {
return isAdvisorModel(advisorModel) ? { advisorModel } : {};
}
/**
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
* that ships cross-session messaging (the feature that makes the peer name matter),
@@ -111,6 +130,7 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st
* @param effort - Optional effort level, injected via --settings (overridable in-session)
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
* @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
@@ -120,11 +140,21 @@ export function buildInteractiveArgs(
allowedTools?: string,
effort?: EffortLevel,
sessionName?: string,
cliVersion?: string | null
cliVersion?: string | null,
advisorModel?: string
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
const effortArgs = buildEffortCliArgs(effort);
const advisor = buildAdvisorSettings(advisorModel);
if (advisor.advisorModel === undefined) {
args.push(...effortArgs);
} else if (effortArgs[0] === '--settings') {
// One --settings flag only: fold the advisor into ultracode's JSON object.
args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor }));
} else {
args.push(...effortArgs, '--settings', JSON.stringify(advisor));
}
args.push(...buildNameCliArgs(sessionName, cliVersion));
return args;
}
+17 -9
View File
@@ -20,7 +20,7 @@
import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
@@ -54,6 +54,8 @@ export interface SpawnBridgeOptions {
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */
advisorModel?: string;
sessionName?: string;
claudeCliVersion?: string | null;
/**
@@ -198,14 +200,20 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortLevel = effortValue;
}
// Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in
// hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude
// Code accepts only one `--settings` flag per invocation — rendering them as two independent
// params would let the second one silently win. Claude-only in practice (statusLineCommand
// is resolved claude-mode-only upstream), but this merge is mode-agnostic.
if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) {
const settingsObj: Record<string, unknown> =
effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {};
// Fold the advisor model and the ephemeral plan-usage statusLine exporter (see
// resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as
// ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation:
// rendering them as independent params would let the last one silently win. Claude-only in
// practice: only claude's launch template renders this engine value, so another CLI's
// session carrying an advisorModel launches exactly as before.
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
// byte-identical to one from before the advisor existed.
const advisorSettings = buildAdvisorSettings(options.advisorModel);
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
const settingsObj: Record<string, unknown> = {
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
...advisorSettings,
};
if (options.statusLineCommand) {
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
}
+556 -25
View File
@@ -42,6 +42,7 @@ import {
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
isAdvisorModel,
isEffortLevel,
type ClaudeMode,
type SessionMode,
@@ -60,8 +61,11 @@ import {
type SessionDocker,
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { probeDockerCliVersion } from './docker-hosts.js';
import { probeRemoteCliVersion } from './remote-hosts.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
@@ -81,6 +85,9 @@ import {
trackActivityStreak,
isSustainedActivity,
isPaneQuiet,
watchingLabel,
isAwaitingWorkers,
WATCHING_TAIL_LINES,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
@@ -497,8 +504,37 @@ 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)
/**
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
*
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
* happened, so it goes stale exactly as that verdict does. The probe returns its
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
*
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
* there, and nothing looks at the pane again until it produces output. That is
* correct rather than merely tolerable, because work ending repaints the pane either
* way — a monitor firing wakes the agent, and codex drops its background-terminal row
* on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane`
* per idle session per tick to learn nothing.
*
* A server restart is not a hole in that either, though it looks like one: this field
* is live state and starts empty. Reconciliation re-attaches the pane, the attach
* repaint carries the composer glyph, and the idle confirmation that arms on it probes
* and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted
* instance, back within ~20 s for a session whose background terminal was still
* running. A session that comes back with no label has no chip on its screen.
*/
private _watching: string | null = null;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
private _watchingLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -542,6 +578,35 @@ export class Session extends EventEmitter {
private _mux: TerminalMultiplexer | null = null;
private _muxSession: MuxSession | null = null;
private _useMux: boolean = false;
/**
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
* stores for every session shape the field does not apply to. See
* {@link PaneExit} for the shapes and for why an unknown answer must never be
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
/**
* How many starts, attaches or relaunches are running for this session's
* pane. While one is, a dead-pane reading may describe a pane that is being
* revived on purpose, so the exited-agent sweep leaves the session alone
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
* operations cannot clear each other's mark.
*/
private _paneLifecycleOps = 0;
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
private _paneStartedAt = 0;
/**
* The server has started closing this session, so no start or attach may
* begin (see {@link markClosing}).
*/
private _closing = false;
/**
* This session was rebuilt from the tmux socket rather than from Codeman's
* own records, so its `remote`/`docker` metadata is missing rather than known
* to be absent. See {@link MuxSession.discovered}.
*/
private _discoveredMuxSession = false;
// Flag to prevent new timers after session is stopped
private _isStopped: boolean = false;
@@ -594,6 +659,11 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined;
// Claude advisor model (code.claude.com/docs/en/advisor), merged into the same launch
// `--settings` JSON as ultracode, never the `--advisor` flag (which exits on a refused
// pairing). A soft default: /advisor still switches or disables it in-session.
private _advisorModel: string | undefined;
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
@@ -710,6 +780,8 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel?: string;
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
@@ -718,6 +790,10 @@ export class Session extends EventEmitter {
lastSubmitAt?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
paneExit?: PaneExit;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
lastActivityAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
@@ -866,10 +942,23 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
if (isAdvisorModel(config.advisorModel)) {
this._advisorModel = config.advisorModel;
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
this._owner = config.owner;
this._discoveredMuxSession = config.discoveredMuxSession === true;
// Restored so a record that says the agent exited survives a server restart
// rather than being blanked by the first persist after boot. It runs here
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
// which are set by now. It is a claim about a pane this process has not
// looked at yet, so every path that starts or re-attaches a pane drops it
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
// replaces it with a first-hand reading. NOT the stats collector, which a
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1097,6 +1186,118 @@ export class Session extends EventEmitter {
return this._muxSession?.muxName ?? null;
}
/**
* True when a tmux pane's death would mean THIS session's agent has exited.
*
* Four shapes fail the test, and each would otherwise publish a death that is
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
* session's local pane holds the ssh client, whose death means a transport
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
* local pane holds a `docker exec` into the container's own tmux.
*
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
* normally means "this is local", but on a discovered record it only means
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
* matches no `state.json` entry, so a remote session rediscovered after
* `mux-sessions.json` was lost arrives looking local, and its next transport
* drop would be published as an agent exit. Unproven locality fails closed.
*/
private get paneExitApplies(): boolean {
if (this._discoveredMuxSession) return false;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
}
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
get paneExit(): PaneExit | undefined {
return this._paneExit ?? undefined;
}
/**
* True while a start, attach or relaunch is running for this session's pane.
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
* until it finishes and clears the exit, the pane still reads as dead.
*/
get paneLifecycleInFlight(): boolean {
return this._paneLifecycleOps > 0;
}
/**
* When the last start, attach or relaunch of this pane finished, or 0 when
* none has run in this process. The exited-agent sweep keeps an exit that
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
* CLI failing at startup rather than a user ending it. An attach to a pane
* that was already running stamps it too, which only costs a user who
* `/exit`s within seconds of a server restart a row to close by hand.
*/
get paneStartedAt(): number {
return this._paneStartedAt;
}
/**
* Mark this session as being closed, or clear the mark after a close that
* failed. While it is set, {@link startInteractive} and {@link startShell}
* refuse to run. A start that raced a close would otherwise launch a CLI in a
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
*/
markClosing(closing: boolean): void {
this._closing = closing;
}
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
this._paneLifecycleOps++;
try {
return await op();
} finally {
this._paneLifecycleOps--;
this._paneStartedAt = Date.now();
}
}
/**
* Forget this pane's exit, on both this record and the mux layer's cache.
* Every path that starts or relaunches a command in the pane calls it, and
* the mux half also invalidates a pane read already in flight.
*
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
* reports no change and therefore writes nothing, so a caller that only
* broadcasts leaves `state.json` saying the agent exited for as long as the
* session stays quiet. `/interactive` and `/shell` did exactly that until
* Ark0N/Codeman#446 review; both now persist on their success path.
*/
private clearPaneExitForNewPane(): void {
this.setPaneExit(undefined);
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
/**
* Record what the mux layer observed of this pane's agent, and say whether
* that changed the answer. The caller persists and broadcasts on a true.
*
* A session the field does not apply to is forced to UNKNOWN here rather than
* at the reporting end, so the rule lives in one place and the mux layer stays
* free to report the raw pane reading its own remote-reconnect watcher needs.
*/
setPaneExit(next: PaneExit | undefined): boolean {
const resolved = this.paneExitApplies ? (next ?? null) : null;
const prev = this._paneExit;
if (prev === resolved) return false;
if (
prev !== null &&
resolved !== null &&
prev.status === resolved.status &&
prev.signal === resolved.signal &&
prev.at === resolved.at
) {
return false;
}
this._paneExit = resolved;
return true;
}
/**
* True when this session's PTY is a tmux client rather than the program itself.
* Read by the replay-side alt-screen strip, which must apply the same
@@ -1118,6 +1319,16 @@ export class Session extends EventEmitter {
return this._isWorking;
}
/**
* What the pane says is still running in the background, e.g. `1 monitor`, or null when
* nothing is. A session with a label here has ended its turn without wanting anything
* from the user, so a surface that would otherwise file it under "needs you" can say
* what it is waiting for instead.
*/
get watching(): string | null {
return this._watching;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -1421,6 +1632,19 @@ export class Session extends EventEmitter {
return this._nameSource;
}
/**
* 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.
*/
get cliPinnedName(): string | undefined {
return this._nameSource === 'manual' ? this._name : undefined;
}
setAutoClear(enabled: boolean, threshold?: number): void {
this._autoOps.setAutoClear(enabled, threshold);
}
@@ -1614,12 +1838,17 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
advisorModel: this._advisorModel,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
// by the constructor: a Codeman restart starts with a fresh breaker so boot
// recovery can re-attach.
respawnBlocked: this._respawnBlocked || undefined,
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
// it rides the existing `session:updated` broadcast and lands in state.json
// through the same persist. `status` and `pid` above stay untouched by it.
paneExit: this._paneExit ?? undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
@@ -1675,6 +1904,7 @@ export class Session extends EventEmitter {
totalCost: this._totalCost,
messageCount: this._messages.length,
isWorking: this._isWorking,
watching: this._watching,
lastPromptTime: this._lastPromptTime,
// Buffer statistics for monitoring long-running sessions
bufferStats: {
@@ -1731,41 +1961,90 @@ export class Session extends EventEmitter {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean }> {
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
}
private async _doSetupOrAttachMuxSession(options: {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
const mux = this._mux!;
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally).
// A session that HAD a mux session relaunches its CLI below just like a failed
// respawn does (tmux kill-server, a tmux crash, an external kill-session), so
// its transcript collides with the bare `--session-id` the same way. A
// genuinely new session starts with `_muxSession` null and never sets this.
let muxSessionVanished = false;
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
muxSessionVanished = true;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
let respawnedDeadPane = false;
let respawnedResumeId: string | undefined;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
// `options.respawnPaneOptions` was built eagerly before this dead-pane
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
const respawnOptions = await this._buildRespawnPaneOptionsWithResumePin();
const newPid = await mux.respawnPane(respawnOptions);
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
respawnedDeadPane = true;
respawnedResumeId = respawnOptions.resumeSessionId;
this._pendingEnvUnsets.clear();
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Whatever the last reading said about the OLD command in this pane is now
// history: the branch above either respawned the pane or found it alive, and
// the branch below creates a new one. The paths that reach here are boot
// recovery and an explicit start, NOT a click on an exited tab — the browser
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
// that an exited pane keeps its pid. `restartCli()` clears separately.
this.clearPaneExitForNewPane();
// Check if we already have a mux session (restored session)
const isRestored = this._muxSession !== null && !needsNewSession;
if (isRestored) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
// Create a new mux session. When this is the FALLBACK after a failed
// respawn, the eagerly-built create options still carry the unpinned
// launch seed, so a session whose transcript exists would meet the same
// `--session-id ... already in use` refusal the respawn just lost to —
// the recovery of last resort failing for the very reason it was needed.
// A genuinely new session has no transcript under any of its candidate
// ids, so nothing is pinned and its command shape is unchanged.
//
// `_resumeSessionId` is written alongside, not just the create options:
// this branch leaves `isRestored` false, so the block that sets
// `_claudeSessionId` below reads that field and would otherwise settle on
// `this.id` while the CLI resumes the chain tail. The response viewer,
// Read My Mind and the unified-list alias map all read `_claudeSessionId`
// until the next first-hand hook, so the two have to name the same
// conversation. The vanished-tmux-session branch above relaunches for the
// same reason and takes the same pin.
if (needsNewSession || muxSessionVanished) {
const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId;
if (pinned) {
options.createSessionOptions.resumeSessionId = pinned;
this._resumeSessionId = pinned;
}
}
this._muxSession = await mux.createSession(options.createSessionOptions);
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
@@ -1800,13 +2079,14 @@ export class Session extends EventEmitter {
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
})
);
this._notePtySpawnGeometry(ptyCols, ptyRows);
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
return { isRestored };
return { isRestored, respawnedResumeId, respawnedDeadPane };
}
/**
@@ -1844,6 +2124,9 @@ export class Session extends EventEmitter {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
return false;
}
// No-op for the record (a remote session's field is always UNKNOWN), but the
// mux layer's cache is keyed by muxName and this pane now runs a new client.
this.clearPaneExitForNewPane();
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
return true;
}
@@ -1869,6 +2152,10 @@ export class Session extends EventEmitter {
* the mux session is gone — see {@link reattachRemote} for that reasoning).
*/
async restartCli(): Promise<boolean> {
return this._withPaneLifecycle(() => this._doRestartCli());
}
private async _doRestartCli(): Promise<boolean> {
if (!this._useMux || !this._mux || !this._muxSession) return false;
const mux = this._mux;
@@ -1878,26 +2165,16 @@ export class Session extends EventEmitter {
}
this._pinOmpRespawnId();
const options = this._buildRespawnPaneOptions();
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
// which turned an endpoint switch into a dead pane and a lost session. A launch that
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
// already use, so pin the live conversation id for THIS respawn only. The registry
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
// read from its own `<Mode>Config` rather than this top-level one anyway.
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
options.resumeSessionId = this._claudeSessionId ?? this.id;
}
const newPid = await mux.respawnPane(options);
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
if (!newPid) {
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
return false;
}
this._pendingEnvUnsets.clear();
// A relaunch in the same pane, so any exit observed of the previous command
// is history. Without this the caller's persist-and-broadcast writes the old
// exit straight back onto a session that is running again.
this.clearPaneExitForNewPane();
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
return true;
}
@@ -1914,6 +2191,7 @@ export class Session extends EventEmitter {
workingDir: this.workingDir,
mode: this.mode,
name: this._name,
cliName: this.cliPinnedName,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
@@ -1939,6 +2217,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -1947,6 +2226,112 @@ export class Session extends EventEmitter {
return this._withCustomModelLaunchModel(options);
}
/**
* Respawn options for a pane whose command is being REPLACED, with the
* conversation pinned so the relaunch resumes rather than collides.
*
* A CLI that launches with `--session-id <id>` refuses an id that is already
* in use (claude: `Error: Session ID ... is already in use.`), and every
* session whose agent has been prompted owns a transcript under that id. So
* relaunching such a pane with the bare launch line fails, the pane dies
* again immediately, and the user's conversation is stranded. A launch that
* declares a `fallback` chain renders `resume || new` once a resume id is
* set, which is the shape that survives both cases.
*
* Three candidates are tried in priority order — the conversation chain's
* tail, the launch seed, then the session's own id — and the first one a
* transcript backs is pinned. Four conditions gate that walk, each protecting
* against a way of resuming the WRONG conversation or of making a working
* relaunch fail.
*
* ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`,
* whose route refuses both, the dead-pane respawn is reached by every session
* shape. Their pane commands (`buildRemoteLaunchCommand`,
* `claudeDockerPaneCommand`) already render a SELF-HEALING
* `--session-id <sid> || --resume <sid>`, and both flip to resume-first the
* moment the resume id differs from the session id. The conversation lives on
* the far side, so a local id pinned onto it resolves to nothing there, the
* resume fails, and the `--session-id` fallback then collides with the
* transcript the far side really does hold — both branches fail and the pane
* dies. `_pinOmpRespawnId()` refuses remote for the same reason.
*
* ⚠️ **The candidates come from the conversation CHAIN, never from
* `_claudeSessionId`.** That field holds either a first-hand id from the
* CLI's own hook payload or a history correlation, which is a guess keyed on
* the working directory. `_recordClaudeSessionInChain()` refuses a guess
* precisely so it cannot "write a foreign conversation into this pane's
* permanent record", and launching from one would do worse than the display
* bug that rule exists to prevent: the relaunched CLI would open and WRITE to
* a conversation that was never this pane's. The chain's tail is the live
* conversation and is hook-vouched, so it leads the walk, ahead of the launch
* seed, which is written once at construction and never moves off a `/clear`.
*
* ⚠️ **Every candidate must be backed by a transcript, the session's own id
* included, and a candidate that has none is passed over rather than ending
* the walk.** A pin that differs from the session id leaves
* `--session-id <this.id>` in the fallback branch, so a resume that finds
* nothing collides there and the pane dies exactly as it did before this
* pinning existed. Pinning `this.id` renders the self-healing
* `--resume <id> || --session-id <id>`, which is correct whether or not a
* transcript exists, but a pane that has none pays for the shape twice:
* claude prints "No conversation found" into the scrollback of a session that
* is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the
* rendered `a || b`, so the branch that actually runs loses its priority for
* the life of the session. Falling off the end of the walk therefore adds
* no pin (the options keep any launch seed they already carried), which is
* the right answer: with no transcript anywhere there is nothing for the
* bare `--session-id <this.id>` to collide with.
*
* The create route pre-validates a resume id for the same reason, though it
* additionally requires the transcript be substantial — here mere existence
* is the question, because a one-line transcript still makes `--session-id`
* collide.
*
* The registry shape is the last gate, not the CLI's name: an entry whose
* resume id is minted by the CLI itself (codex/pi/omp/grok) declares no
* `fallback` chain and reads its resume field from its own `<Mode>Config`.
*
* `reattachRemote()` deliberately does NOT call this. It re-runs the remote
* session command, which attaches to the durable remote tmux with the agent
* still inside it and renders no local `--session-id` to collide.
*/
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
const options = this._buildRespawnPaneOptions();
if (this._remote || this._docker) return options;
const entry = getCli(this.mode);
if (entry?.launch.chain !== 'fallback') return options;
const resumeIdPattern = entry.launch.params?.resumeId;
const configDir = this._claudeConfigDir();
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
for (const candidate of candidates) {
// A session Codeman DISCOVERED on the socket rather than created carries a
// synthetic `restored-<fragment>` id, which fails claude's `uuid` token
// pattern. The renderer would silently drop the resume flag and emit the
// unpinned command, so say so here rather than letting the caller believe
// the pane was pinned.
if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) {
console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`);
continue;
}
if (!(await claudeTranscriptExists(candidate, configDir))) continue;
options.resumeSessionId = candidate;
return options;
}
// Nothing on disk to collide with, so the bare `--session-id <this.id>` the
// unpinned options already carry is the correct command.
return options;
}
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
private _claudeConfigDir(): string | undefined {
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
// envOverrides schema validates keys only, and a whitespace-only value would
// otherwise resolve to a relative path and read "no transcript" for everything.
return this._envOverrides?.CLAUDE_CONFIG_DIR?.trim() || undefined;
}
/**
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
@@ -2160,6 +2545,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
if (this._closing) {
throw new Error('Session is being closed');
}
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup.
@@ -2268,7 +2656,7 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
const { isRestored } = await this._setupOrAttachMuxSession({
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
// Single source of truth shared with reattachRemote() (COD-108).
respawnPaneOptions: this._buildRespawnPaneOptions(),
createSessionOptions: {
@@ -2276,6 +2664,7 @@ export class Session extends EventEmitter {
workingDir: this.workingDir,
mode: this.mode,
name: this._name,
cliName: this.cliPinnedName,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
@@ -2291,6 +2680,7 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2315,7 +2705,18 @@ export class Session extends EventEmitter {
// persisted chain's tail is that conversation, reported first-hand by
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
// an empty chain and falls through to the resume/alias fallbacks.
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
//
// A dead-pane respawn is NOT that case for a CLI whose relaunch the resume
// pin walk governs (`launch.chain === 'fallback'`): the CLI did stop, and
// the walk may have passed over a chain tail with no transcript behind it,
// so the conversation is whatever the respawn actually resumed. Undefined
// there means the pane launched unpinned, which the fallbacks below name.
const pinGovernsRespawn = respawnedDeadPane && getCli(this.mode)?.launch.chain === 'fallback';
restoredConversation = pinGovernsRespawn
? respawnedResumeId
: isRestored
? this._claudeSessionChain[this._claudeSessionChain.length - 1]
: undefined;
this._claudeSessionId =
restoredConversation ||
this._resumeSessionId ||
@@ -2403,8 +2804,9 @@ export class Session extends EventEmitter {
this._model,
this._allowedTools,
this._effort,
this._name,
getClaudeCliVersion()
this.cliPinnedName,
getClaudeCliVersion(),
this._advisorModel
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
@@ -2416,6 +2818,7 @@ export class Session extends EventEmitter {
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
})
);
this._notePtySpawnGeometry(120, 40);
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
this._status = 'stopped';
@@ -2708,10 +3111,82 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
// A turn that ended by handing off to workers the CLI waits for is work too: the
// composer is up and the pane is quiet, but the next turn starts without the user.
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
this._readWatching(text);
return this._lastPaneProbeWorking;
}
/**
* Read the background-work chip off the same capture the working probe just took.
*
* The two questions are different. A turn that is running is work the user is waiting
* for; a monitor, a backgrounded shell or a cloud session the agent started is work
* the AGENT is waiting for, and it is the reason a pane can sit at its composer with
* nothing to say and still not want anything from the user. `_confirmIdle` takes this
* capture at exactly the moment the turn ends, which is the moment the answer starts
* mattering.
*
* A capture that could not be read CLEARS the label rather than keeping the last one.
* The two wrong answers are not symmetric: a stale label opens the next idle prompt
* already acknowledged, so a failed capture would silence a real alert, while a dropped
* label only costs a card and an alert that the next readable capture takes back.
* Degrading toward the alert is the rule the whole signal is built on.
*/
private _readWatching(paneText: string | null): void {
const pattern = this._watchingLinePattern();
if (!pattern) return;
// `null` is "the screen could not be read". That is no evidence either way, so the
// label falls to null (and the change is announced below like any other).
const label = paneText === null ? null : watchingLabel(paneText, pattern, this._watchingWindow);
if (label === this._watching) return;
this._watching = label;
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
// own. The label is usually set on the idle transition, which broadcasts anyway, but
// it CLEARS when the work ends — and for a CLI whose background work ends without
// taking a turn (measured on codex: a background terminal finishing repaints the row
// away and nothing else happens) the session is idle before and after. Without this,
// the server knew the badge was gone and every open page went on drawing it until
// some unrelated event arrived.
this.emit('watchingChanged');
}
/**
* The regex matching this CLI's background-work chip, or null for a CLI whose registry
* entry declares none. Compiled once per session, like the working-line pattern, and
* null rather than a fallback: no other CLI has been measured drawing such a chip, and
* guessing one would badge sessions on the strength of an unread screen.
*/
private _watchingLinePattern(): RegExp | null {
if (this._watchingLineRe === undefined) {
// The pattern and the window it runs over are one decision, so they are resolved
// together: how far up the screen a CLI's row can sit is as much a property of its
// layout as the row itself. Claude writes on the last row and keeps the default,
// Codex pins one above its composer and declares more.
const detect = getCli(this.mode)?.capabilities.workDetect;
this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null;
this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES;
}
return this._watchingLineRe;
}
/**
* Whether the newest turn on this screen ended waiting for workers the CLI started
* (Claude's `✻ Waiting for 1 dynamic workflow to finish`). False for a CLI whose
* registry entry declares no `awaitingLine`. See `isAwaitingWorkers()`.
*/
private _paneAwaitsWorkers(paneText: string): boolean {
if (this._awaitingLineRe === undefined) {
const src = getCli(this.mode)?.capabilities.workDetect?.awaitingLine;
this._awaitingLineRe = src ? compileVersionRegex(src) : null;
}
if (!this._awaitingLineRe) return false;
const glyph = getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯';
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
@@ -2914,6 +3389,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
if (this._closing) {
throw new Error('Session is being closed');
}
this._resetBuffers();
@@ -2984,6 +3462,7 @@ export class Session extends EventEmitter {
env: buildShellEnv(this.id),
})
);
this._notePtySpawnGeometry(120, 40);
} catch (spawnErr) {
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
this._status = 'stopped';
@@ -3093,6 +3572,7 @@ export class Session extends EventEmitter {
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
})
);
this._notePtySpawnGeometry(120, 40);
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
this.emit(
@@ -3742,6 +4222,16 @@ export class Session extends EventEmitter {
return false;
}
/**
* Arm the composer check for a prompt that went out some other way than
* `writeViaMux`, e.g. cron's paste mode, which writes the body raw and its Enter
* separately. `text` is what the composer line starts with while the prompt is still
* unsent; the check re-presses Enter only while that holds.
*/
verifySubmitted(text: string): void {
this._verifySubmitted(`${text}\r`);
}
/**
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
@@ -3779,6 +4269,40 @@ export class Session extends EventEmitter {
private _ptyCols = 120;
private _ptyRows = 40;
/**
* Record the geometry a PTY was just spawned at. A reattached pane keeps the
* tmux window's size, not the constructor's 120x40, and without this
* `ptyGeometry` reported the old numbers for a live pane and the dedupe in
* `resize()` skipped a real resize that happened to match them.
*/
private _notePtySpawnGeometry(cols: number, rows: number): void {
this._ptyCols = cols;
this._ptyRows = rows;
}
/**
* The geometry the CLI is actually drawing for, or null when nothing is
* drawing.
*
* Exposed because `resize()` can decline a request outright (arbitration
* below) and the asking client has no other way to find out: a browser
* terminal that keeps a shape the PTY refused renders garbled output rather
* than wrong-sized output, because Claude Code's repaints are computed from
* the width it was told (issue #464). Both transports report this back.
*
* ⚠️ NULL WITHOUT A PANE, never the field values. The fields are seeded at
* spawn (`_notePtySpawnGeometry`) and moved by `resize()`, but a session with
* a dead pane (or one created through the API and never started) still
* holds the constructor defaults of 120x40, or the size of a pane that is
* gone. Reporting those made a client adopt a size no process had ever
* been told, and on anything narrower than 120 columns it claimed another
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
* report with no finite numbers as no evidence, which is the truth here.
*/
get ptyGeometry(): { cols: number; rows: number } | null {
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
}
/**
* Live WebSocket connections that have announced a desktop viewport for this
* session. While at least one is registered, small-viewport (mobile/tablet)
@@ -3864,6 +4388,10 @@ export class Session extends EventEmitter {
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
// Declined. The caller is told nothing here on purpose — the decision
// belongs to the session, not the socket — but the caller MUST report
// `ptyGeometry` back afterwards so the asking client can adopt
// the shape it did not get. Both transports do; see issue #464.
return;
}
this._mobileSizeOverride = true;
@@ -3963,6 +4491,9 @@ export class Session extends EventEmitter {
async stop(killMux: boolean = true): Promise<void> {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
// without this the last chip it drew would ride along on its row forever.
this._watching = null;
this._clearAllTimers();
+402 -19
View File
@@ -58,6 +58,7 @@ import {
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
type PaneExit,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -152,6 +153,16 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/**
* How often the pane-exit watcher re-reads every pane on the socket. The
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
* Matched to the stats cadence above because both cost one batched tmux read.
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
*/
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
@@ -219,8 +230,18 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
*/
const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
*
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
* Appending them is backward compatible in both directions. A tmux that does not
* know a variable substitutes the empty string rather than failing, which is how
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
* short row as "pid known, deadness unknown" rather than discarding it.
*/
const PANE_LIST_FORMAT =
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
@@ -235,26 +256,154 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
export interface PaneRow {
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
sessionName: string;
/** `#{pane_pid}` — the process tmux started in the pane. */
pid: number;
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
dead?: boolean;
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
exitStatus?: number;
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
exitSignal?: number;
}
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
* One pane-exit reading, with the pane pid that produced it.
*
* The pid never leaves this module. It is what distinguishes "the same dead
* pane, seen again" from "a second command in the same pane that also exited
* with the same status", so the `at` stamp can hold across the first and must
* not across the second. {@link PaneExit} itself stays free of it: the pid on
* the session record is the attach client's, and a second pid there would
* invite exactly the confusion Ark0N/Codeman#446 is about.
*/
export interface PaneExitObservation {
/** `#{pane_pid}` of the pane this reading came from. */
panePid: number;
/** What to publish on the session record. */
exit: PaneExit;
}
/**
* A {@link PaneExitObservation} as the manager stores it, with a count of the
* authoritative reads that have seen this same exit. The count is what lets
* the exited-agent sweep act only on a death that more than one read agreed on
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
* the count nor resets it.
*/
interface TrackedPaneExit extends PaneExitObservation {
/** Authoritative reads that saw this exit, counting the first. */
reads: number;
}
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField(fields: string[], index: number): number | undefined {
const raw = fields[index];
if (raw === undefined || raw === '') return undefined;
const value = parseInt(raw, 10);
return Number.isNaN(value) ? undefined : value;
}
/**
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
* into one row per pane, in tmux's own order. Exported for unit testing.
*
* - Skips empty lines and lines without the separator.
* - Skips entries with a non-numeric pid or empty name.
* - Leaves every field after the pid undefined when it is blank or absent, so a
* row from an older tmux still yields its pid.
*/
export function parsePaneList(output: string): Map<string, number> {
const result = new Map<string, number>();
export function parsePaneRows(output: string): PaneRow[] {
const rows: PaneRow[] = [];
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf(PANE_LIST_SEP);
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
result.set(name, pid);
}
if (!line.includes(PANE_LIST_SEP)) continue;
const fields = line.split(PANE_LIST_SEP);
const sessionName = fields[0];
const pid = parseInt(fields[1] ?? '', 10);
if (!sessionName || Number.isNaN(pid)) continue;
const deadFlag = fields[2];
rows.push({
sessionName,
pid,
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
exitStatus: paneField(fields, 3),
exitSignal: paneField(fields, 4),
});
}
return result;
return rows;
}
/**
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
* Exported for unit testing. Returns one entry per session with a known answer;
* a session absent from the map is UNKNOWN, which must never render as alive.
*
* Two rules make a positive answer trustworthy:
*
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
* creates one pane per session and `isPaneDead()` reads one pane, so a session
* the user has split by hand has no single "the agent" to report on, and
* guessing which of its panes speaks for the session could report a live
* session as exited.
*
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
* that did not answer, not a live pane.
*
* `status` and `signal` stay absent when tmux did not report them. Measured on
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
* 0 would turn an unexplained death into a clean exit.
*/
export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneExitObservation> {
const panesPerSession = new Map<string, number>();
for (const row of rows) {
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
}
const exits = new Map<string, PaneExitObservation>();
for (const row of rows) {
if (panesPerSession.get(row.sessionName) !== 1) continue;
if (row.dead !== true) continue;
exits.set(row.sessionName, {
panePid: row.pid,
exit: {
...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
at: now,
},
});
}
return exits;
}
/**
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
* for unit testing.
*
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
* remote session's local pane holds the ssh client, a docker case's holds a
* `docker exec` into the container's own tmux, and a record rebuilt from the
* socket carries no provenance at all, so the session end forces all three to
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
* the answer.
*
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
* with nothing to observe and picks the read straight back up as soon as one
* local session exists.
*/
export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolean {
for (const session of sessions) {
if (session.remote) continue;
if (session.docker) continue;
if (session.discovered === true) continue;
return true;
}
return false;
}
/**
@@ -733,9 +882,11 @@ export function buildSpawnCommand(options: {
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */
advisorModel?: string;
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
@@ -1527,6 +1678,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
private lastPaneCount: Map<string, number> = new Map();
/**
* muxName → the exited agent the pane-exit watcher last observed
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
* entry goes the moment tmux stops reporting the pane dead, and the map is
* empty until the first read runs. The manager reports what tmux says and
* nothing more: the scoping that hides this for remote and docker sessions
* lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading.
*/
private paneExits: Map<string, TrackedPaneExit> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
* poll interval of 2000 ms, so without this a slow read overlaps the next two
* and the older one can resolve last and win.
*/
private paneExitReadInFlight = false;
/**
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
* a clear carries the older generation and is discarded rather than writing
* the death back over the pane that has just replaced it.
*/
private paneExitGeneration = 0;
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
/** Periodic watcher that re-establishes dropped remote sessions. */
@@ -1894,6 +2069,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
mode,
name,
cliName,
niceConfig,
model,
claudeMode,
@@ -1909,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
envOverrides,
effort,
advisorModel,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
@@ -1996,8 +2173,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: name,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2223,9 +2401,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
unsetEnvKeys,
effort,
advisorModel,
remote,
docker,
name,
cliName,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -2261,8 +2440,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: name,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
@@ -2297,6 +2477,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
);
// Wait for the respawned process to start
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
// The pane now runs a fresh command, so whatever the last read observed of
// the old one is history. Clearing it here rather than waiting for the next
// poll also invalidates any read already in flight, which would otherwise
// write the old death back over the pane that just replaced it.
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
return pid;
@@ -2508,6 +2693,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
@@ -2618,6 +2804,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
@@ -2671,7 +2858,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
active = parsePaneList(output);
const rows = parsePaneRows(output);
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
// The same read answers both questions, so recovery starts with a pane-exit
// reading rather than waiting for the first stats tick — which may never
// come, since the collector only starts when boot found a live session.
if (rows.length > 0) {
this.applyPaneExits(derivePaneExits(rows, Date.now()));
}
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
@@ -2686,6 +2880,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
this.clearPaneExit(session.muxName);
this.clearRemoteReconnectState(sessionId);
this.emit('sessionDied', { sessionId });
}
@@ -2722,6 +2917,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode: 'claude',
attached: false,
name: `Restored: ${sessionName}`,
// Every field above except the name and the pid is a guess: this record
// was rebuilt from the socket because Codeman's own bookkeeping did not
// have it. The synthetic id also cannot find the session's state.json
// entry, so a remote or docker session rediscovered this way arrives
// looking local. Consumers that would be wrong about such a session
// read this flag and fail closed — see `Session.paneExitApplies`.
discovered: true,
};
this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
@@ -2882,6 +3084,185 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}));
}
/**
* What the last pane read saw of this tmux session's agent. `undefined` is
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
* that is running, a tmux session that no longer exists, a probe that failed,
* and every poll that has not run yet. See {@link PaneExit}.
*/
getPaneExit(muxName: string): PaneExit | undefined {
return this.paneExits.get(muxName)?.exit;
}
/**
* How many authoritative pane reads have agreed on the exit that
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
* starts at 1, and every later read that sees the same pane with the same
* status and signal adds one.
*/
getPaneExitReadCount(muxName: string): number {
return this.paneExits.get(muxName)?.reads ?? 0;
}
/**
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
* `tmux list-panes -a` answers for every session at once, which is why this
* polls rather than probing per session.
*
* A failed or empty probe leaves the previous answers ALONE rather than
* clearing them, because the two cannot be told apart: the command ends in
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
* arrive as empty output. Treating that as "tmux did not answer" is the
* conservative reading — clearing on it would turn a transient failure into a
* silent retraction of a death Codeman had already observed, and the cost of
* being wrong the other way is one stale entry for a socket that no longer
* has the pane. A NON-empty read is different: `list-panes -a` lists
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
* prunes against it.
*
* Two guards keep a slow read from undoing a fast one. A read already in
* flight suppresses the next poll, and a read that started before a
* {@link clearPaneExit} is discarded when it lands.
*
* A third guard skips the read entirely while no session on this manager
* could produce an answer ({@link hasObservablePaneSession}). Skipping
* retracts nothing, for the same reason a failed read does not: the map
* still holds what the last real read saw, and every path that puts a new
* command in a pane calls {@link clearPaneExit} itself.
*/
async refreshPaneExits(now: number = Date.now()): Promise<void> {
// Nothing on this socket could answer, so do not read tmux to find that
// out. See `hasObservablePaneSession`: the watcher above still ticks.
if (!hasObservablePaneSession(this.sessions.values())) return;
if (this.paneExitReadInFlight) return;
const generation = this.paneExitGeneration;
this.paneExitReadInFlight = true;
let rows: PaneRow[];
try {
rows = await this.readPaneRows();
} finally {
this.paneExitReadInFlight = false;
}
if (rows.length === 0) return;
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
if (generation !== this.paneExitGeneration) return;
this.applyPaneExits(derivePaneExits(rows, now));
}
/**
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
* touches tmux, which is what lets a test subclass drive the guards in
* {@link refreshPaneExits} — the in-flight suppression, the generation
* check, the empty-read retraction rule and the read gate — against rows it
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
* test can reach is a guard that can be deleted without anything failing.
*
* A failed read answers with NO rows, which the caller treats as "tmux did
* not answer" and which therefore retracts nothing.
*/
protected async readPaneRows(): Promise<PaneRow[]> {
// The test-mode gate lives HERE rather than at the top of the tick, so that
// what tests cannot do is spawn a process, not exercise the bookkeeping.
if (IS_TEST_MODE) return [];
try {
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
// exec freezes the port while the process stays alive (see the
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
// stay synchronous because each is answering one request right then.
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return parsePaneRows(stdout.trim());
} catch (err) {
console.error('[TmuxManager] Failed to read pane exit state:', err);
return [];
}
}
/**
* Fold one authoritative observation into {@link paneExits}. Split out from
* the tmux call so the merge rules are unit-testable.
*
* `observed` comes from a read of EVERY pane on the socket, so a session
* missing from it has no exit to report and its entry goes. That is what
* keeps the map from growing without bound as tmux sessions come and go
* outside `killSession()`. Only the caller may decide a read is authoritative:
* a failed or empty one never reaches here.
*
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
* says when the agent was found gone rather than when the last poll ran. A
* changed status, a changed signal, or a different pane pid all start a new
* observation — the pid is what catches a second command in the same pane
* that happened to exit the same way.
*
* The same rule decides the read count: a repeat of the stored exit adds one,
* and anything that starts a new observation starts the count again at 1.
*/
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
for (const muxName of [...this.paneExits.keys()]) {
if (!observed.has(muxName)) this.paneExits.delete(muxName);
}
for (const [muxName, next] of observed) {
const prev = this.paneExits.get(muxName);
const sameExit =
prev !== undefined &&
prev.panePid === next.panePid &&
prev.exit.status === next.exit.status &&
prev.exit.signal === next.exit.signal;
this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 });
}
}
/**
* Forget a session's exit observation, e.g. once its pane has been respawned.
* Also invalidates any read already in flight, so the answer this retracts
* cannot be written back a moment later.
*/
clearPaneExit(muxName: string): void {
this.paneExits.delete(muxName);
this.paneExitGeneration++;
}
/**
* Poll for exited agents, on the manager's own interval.
*
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
* when the browser opens the Monitor panel and DISARMED when it closes it
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
* recovered — so riding it would leave a session created on a freshly booted
* server reporting nothing at all, and would let one browser turn exit
* detection off for every other. Started unconditionally, like the mouse-mode
* sync and the remote-reconnect watcher below.
*
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
* SSE event, and the field reaches the browser on `session:updated`.
*/
startPaneExitWatcher(intervalMs: number = DEFAULT_PANE_EXIT_INTERVAL_MS): void {
if (this.paneExitInterval) {
clearInterval(this.paneExitInterval);
}
this.paneExitInterval = setInterval(() => {
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
// spawn a process and it refuses under test, so a test can drive this
// whole loop with fake timers instead of being locked out of it.
void this.refreshPaneExits()
.then(() => this.emit('paneExitsUpdated'))
.catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
}, intervalMs);
}
stopPaneExitWatcher(): void {
if (this.paneExitInterval) {
clearInterval(this.paneExitInterval);
this.paneExitInterval = null;
}
}
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
if (this.statsInterval) {
clearInterval(this.statsInterval);
@@ -3101,6 +3482,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
destroy(): void {
this.stopStatsCollection();
this.stopPaneExitWatcher();
this.paneExits.clear();
this.stopMouseModeSync();
this.stopRemoteReconnectWatcher();
this.reconnectState.clear();
+18 -5
View File
@@ -28,8 +28,11 @@
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
import type { TuiApprovalAnswer } from './tui-client.js';
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
export type TuiApprovalTone = 'err' | 'warn';
/**
* Card severity, in the same red/yellow vocabulary the web inbox uses, plus the quiet
* third case: an item that opened acknowledged asks for nothing and reads grey.
*/
export type TuiApprovalTone = 'err' | 'warn' | 'info';
export interface TuiApprovalCard {
tone: TuiApprovalTone;
@@ -50,8 +53,14 @@ function clean(text: string | undefined): string {
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
}
/**
* How loud the card is. An idle prompt the inbox opened ALREADY acknowledged is not
* asking for anything — the session is watching work it started itself — so it drops to
* `info` and out of the warning vocabulary the other two share with the web inbox.
*/
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
return item.kind === 'idle' ? 'warn' : 'err';
if (item.kind !== 'idle') return 'err';
return item.acknowledgedReason ? 'info' : 'warn';
}
/**
@@ -65,9 +74,13 @@ export function approvalCard(item: ApprovalItem): TuiApprovalCard {
const summary = clean(item.toolSummary) || clean(item.toolName);
if (item.kind === 'idle') {
// Say what it is waiting for rather than asking for a reply, in the same words the
// web drawer uses for the same item. The prompt is still answerable, so the hint
// stays either way.
const quiet = clean(item.acknowledgedReason);
return {
tone: 'warn',
title: message || 'waiting for your reply',
tone: approvalTone(item),
title: quiet ? `quiet, ${quiet}` : message || 'waiting for your reply',
detail: [],
options: [],
hint: 'p to reply',
+18 -2
View File
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
* outranks a stale `busy` status because the hook is the newer signal. An
* errored session has no state of its own here and joins the waiting tier,
* since it is equally something only a human can clear.
*
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
* alert this prompt armed has been spent, either because somebody opened the
* session on another device or because the inbox opened the item that way for a
* session watching its own background work. The item itself stays pending and
* answerable, so the row keeps carrying it and the approval card still renders;
* it simply stops dragging the session into NEEDS YOU. The web has honoured
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
* reading past it: acknowledging on a phone cleared the alert everywhere except
* here. Only `idle` can be acknowledged, so a permission or question dialog is
* unaffected by construction, and both are checked ahead of the flag anyway.
*/
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
if (!isLiveRow(session)) return 'recent';
if (approval) {
if (approval.kind === 'permission') return 'blocked-permission';
if (approval.kind === 'question') return 'blocked-question';
return 'waiting';
if (!approval.acknowledgedAt) return 'waiting';
}
if (session.status === 'error') return 'waiting';
if (session.isWorking === true || session.status === 'busy') return 'working';
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
* turn's own start is the pane's last Enter.
*/
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
if (approval) return approval.createdAt;
// The prompt's own age measures the state only while the prompt is what put the
// row in that state. An acknowledged item still rides along on a row that is
// plainly idle or working, and dating such a row from it would report how long
// ago the prompt arrived as though it were how long the session has been quiet.
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
return session.lastActivityAt ?? session.createdAt ?? 0;
}
+15 -5
View File
@@ -462,7 +462,8 @@ export function computeListWindow(
* The pending dialog, drawn above the tail: the question, the parsed options
* with their digits, and the keys that answer them. Red for a permission or
* question prompt, yellow for an idle one, the same severity vocabulary the web
* inbox uses.
* inbox uses. An idle prompt that opened acknowledged carries neither: it reads
* grey with the idle glyph, because nothing about it wants the reader.
*/
export function renderApprovalCard(
item: ApprovalItem,
@@ -472,8 +473,8 @@ export function renderApprovalCard(
): string[] {
const paint = painterFor(opts.color);
const card = approvalCard(item);
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
const color = card.tone === 'err' ? SGR.red : card.tone === 'warn' ? SGR.yellow : SGR.gray;
const glyph = card.tone === 'err' ? glyphs.blockedPermission : card.tone === 'warn' ? glyphs.waiting : glyphs.idle;
const lines: string[] = [];
const push = (text: string, style: string): void => {
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
@@ -571,10 +572,19 @@ function previewBody(
// Chrome
// ─────────────────────────────────────────────────────────────────────────────
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
/**
* Sessions with a prompt waiting on a human, which is what the badge counts.
*
* An ACKNOWLEDGED item is not one of them. Its alert has been spent, either by somebody
* opening the session elsewhere or because the inbox opened it that way for a session
* watching its own background work, and the row has already left NEEDS YOU by the same
* flag (`classifySession`). Counting it here would put a number in the header for a
* group the reader can see is empty.
*/
export function pendingApprovalCount(model: TuiRenderModel): number {
let count = 0;
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
for (const group of model.groups())
for (const row of group.rows) if (row.approval && !row.approval.acknowledgedAt) count++;
return count;
}
+86
View File
@@ -377,6 +377,26 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
* out on purpose: it can call an advisor but never act as one.
*/
export const ADVISOR_MODEL_ALIASES = ['fable', 'opus', 'sonnet'] as const;
/** A full model id in one of the advisor-capable families, e.g. `claude-opus-5-5`. */
const ADVISOR_MODEL_ID_PATTERN = /^claude-(?:fable|opus|sonnet)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Type guard: is the value an advisor model Codeman will pass to claude? An alias from
* ADVISOR_MODEL_ALIASES or a full fable/opus/sonnet model id. ⚠️ This allowlist is also the
* injection guard: the value is rendered inside the single-quoted `--settings` JSON argument.
*/
export function isAdvisorModel(value: unknown): value is string {
if (typeof value !== 'string' || value.length > 64) return false;
return (ADVISOR_MODEL_ALIASES as readonly string[]).includes(value) || ADVISOR_MODEL_ID_PATTERN.test(value);
}
/** OpenCode session configuration */
export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -625,6 +645,55 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
launchModel?: string;
}
/**
* The agent inside a LOCAL tmux pane has exited, and the pane survived it.
*
* Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI
* while tmux keeps the pane, the tmux session and the `tmux attach-session`
* process Codeman records as the session's pid. No PTY exit handler runs, so
* without this record the session reads as a live idle one (Ark0N/Codeman#446).
*
* The field is TRI-STATE, and the third state is the absence of the field:
* `undefined` means Codeman does not know, and it must never be rendered as
* "alive". It is absent for a direct-PTY session (no pane exists), for a remote
* SSH session (the local pane holds the ssh client, whose death means transport
* drop OR exit) and for a docker case (the local pane holds a `docker exec`
* into the container's own tmux).
*
* `status` and `signal` are independently optional because tmux may know that
* the pane died without reporting how. Measured on tmux 3.2a: a SIGKILLed pane
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
* empty, and `#{pane_dead_signal}` does not exist at all before tmux 3.4. So an
* absent `status` means "the exit code is unknown", never "the exit code is 0".
*
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
* the absent status IS how a signal death presents, so absent-stays-absent is
* the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
* read as a user typing `/exit` and be closed. Nothing here fails when
* somebody adds that `??` — the types allow it and the label still renders.
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
* accepts only an explicit 0.
*/
export interface PaneExit {
/**
* tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux
* reported none, which means UNKNOWN and never 0. See the ⚠ above before
* giving this a default anywhere.
*/
status?: number;
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
signal?: number;
/**
* Wall-clock ms when THIS server process first observed the pane dead. It is
* not when the agent exited, which nothing records, and a restart that finds
* the pane still dead respawns it rather than re-timing the old exit.
*/
at: number;
}
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -746,6 +815,8 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
advisorModel?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
@@ -780,6 +851,21 @@ export interface SessionState {
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
*/
respawnBlocked?: boolean;
/**
* The agent in this session's LOCAL tmux pane has exited (Ark0N/Codeman#446).
* See {@link PaneExit} for the tri-state rule and for which session shapes
* leave it absent. `status` and `pid` are deliberately untouched by it: the
* PTY-exit breaker owns `status: 'error'`, and a null `pid` is what makes the
* browser re-attach and launch a fresh CLI.
*
* Persisted so a reboot restore can tell a session whose agent exited from one
* that was merely idle when the power went. `reboot-restore.ts` reads the
* persisted record and never builds a `Session`, so the record is the only
* place that survives the reboot to carry it. Nothing reads it there YET:
* making the restore refuse such a session is a behavior change, and it
* belongs with the part of Ark0N/Codeman#446 that closes exited sessions.
*/
paneExit?: PaneExit;
}
/**
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Does a Claude conversation transcript exist on this host?
*
* Claude writes one `<conversation-id>.jsonl` per conversation under
* `<config dir>/projects/<mangled cwd>/`. Two launch decisions turn on whether
* such a file exists: `--resume <id>` needs one, and `--session-id <id>` is
* REFUSED when one exists (`Error: Session ID ... is already in use.`).
*
* The project directory name is derived from the working directory, and a case
* that has been moved or renamed leaves its transcript under the OLD name, so
* the search is across every project directory rather than the one that matches
* the pane's cwd today.
*
* ⚠️ Existence is the whole question here, with no size floor. The create route
* additionally requires ~4 KB before it will resume, which is a "is this
* conversation worth resuming" judgement; for a relaunch the question is the
* opposite one — a one-line transcript still makes `--session-id` collide.
*
* ⚠️ A false answer is not the conservative one. Skipping a resume leaves the
* relaunch on `--session-id <id>`, which is safe only when no transcript backs
* that id either, so a lookup that misses the real config dir turns a
* recoverable pane into the collision this module exists to prevent.
*
* @dependencies none
* @consumedby session (relaunch resume pinning)
*
* @module utils/claude-transcript
*/
import { readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* `<config dir>/projects`, honouring a session's relocated `CLAUDE_CONFIG_DIR`
* (#255) and, failing that, the server process's own.
*
* ⚠️ The process env is not optional here. A pane inherits the server's
* environment through tmux, so on an install that exports `CLAUDE_CONFIG_DIR`
* the CLI writes its transcripts there and a lookup under `~/.claude` answers
* "no transcript" for every conversation on the host. `claudeCredentialsPath()`
* (claude-credentials.ts) and `realClaudeConfigDir()`
* (custom-model-injection-apply.ts) resolve the same directory the same way.
*/
export function claudeProjectsDir(configDir?: string): string {
const fromEnv = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
}
/**
* True when a transcript for `conversationId` exists under any project
* directory. Returns false for a missing projects dir or an unreadable one,
* which leaves the caller unpinned: safe where nothing else can collide with
* the bare `--session-id`, and the reason the caller walks its candidates down
* to the session's own id rather than treating one false answer as final.
*/
export async function claudeTranscriptExists(conversationId: string, configDir?: string): Promise<boolean> {
if (!conversationId) return false;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return false;
}
for (const projectDir of projectDirs) {
try {
await stat(join(projectsDir, projectDir, `${conversationId}.jsonl`));
return true;
} catch {
// Not in this project directory; keep looking.
}
}
return false;
}
+31
View File
@@ -204,6 +204,28 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
};
}
/**
* Per-binary invalidation generation, bumped by `invalidateCliExecutableResolvers()`.
*
* Every resolver instance (each per-CLI module's private one AND the generic registry
* resolver in cli-resolver.ts) is built by the factory below and caches in its own
* closure, so there is no instance to reach from outside. Keying on the BINARY name is
* what lets one call reach all of them: the CLI-management install/update routes know
* which binaries just changed, and every resolver knows its own.
*/
const binaryGenerations = new Map<string, number>();
/**
* Forget every cached result — success and negative-cache backoff alike — for these
* binaries, so the next `resolve()` re-runs the chain immediately. For an action that
* just changed what is on disk (an install) or what a CLI's binary IS (editing a custom
* entry): without it a CLI installed from Settings kept reading as missing for up to the
* 5-minute backoff, and an edited entry kept launching its old binary until a restart.
*/
export function invalidateCliExecutableResolvers(binaries: readonly string[]): void {
for (const binary of binaries) binaryGenerations.set(binary, (binaryGenerations.get(binary) ?? 0) + 1);
}
export function createCliExecutableResolver<T = undefined>(
options: {
binary: string;
@@ -235,6 +257,8 @@ export function createCliExecutableResolver<T = undefined>(
let failures = 0;
/** Timestamp of the most recent miss. */
let lastFailureAt = 0;
/** The invalidation generation the cached state above belongs to. */
let generation = binaryGenerations.get(options.binary) ?? 0;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
@@ -249,6 +273,13 @@ export function createCliExecutableResolver<T = undefined>(
return {
resolve() {
const current = binaryGenerations.get(options.binary) ?? 0;
if (current !== generation) {
generation = current;
cached = null;
failures = 0;
lastFailureAt = 0;
}
if (cached) return cached;
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview One answer to "is this CLI installed here?", shared by the page render
* (`renderIndexHtml` in server.ts, which injects `window.__codemanCliAvailable` and
* `window.__codemanCliCatalog`) and `GET /api/clis` (the Settings list's badge).
*
* The two used to keep their own copies of the per-CLI probe map, so they could drift apart
* 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.
*/
import type { CliEntry } from '../config/cli-registry/types.js';
import { isCliAvailable as isRegistryCliAvailable } from './cli-resolver.js';
/**
* The stock CLIs whose own resolver answers availability. It keeps the resolver's specific
* semantics (pi/grok/deepseek identity probes). DeepSeek reports RUNNABLE here, not merely
* installed: `dsh` is a profile launcher, and a dsh with no pane-capable profile would
* offer a Run button that spawns a pane which dies on arrival.
*/
export async function probeStockCliAvailability(): Promise<Record<string, boolean>> {
const [
{ isClaudeAvailable },
{ isOpenCodeAvailable },
{ isCodexAvailable },
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable },
{ isOmpAvailable },
] = await Promise.all([
import('./claude-cli-resolver.js'),
import('./opencode-cli-resolver.js'),
import('./codex-cli-resolver.js'),
import('./gemini-cli-resolver.js'),
import('./antigravity-cli-resolver.js'),
import('./pi-cli-resolver.js'),
import('./grok-cli-resolver.js'),
import('./deepseek-cli-resolver.js'),
import('./omp-cli-resolver.js'),
]);
return {
claude: isClaudeAvailable(),
opencode: isOpenCodeAvailable(),
codex: isCodexAvailable(),
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
deepseek: isDeepSeekRunnable(),
omp: isOmpAvailable(),
};
}
/**
* Is `entry` installed? A shell entry has no binary to probe, since it is the server's own
* login shell. A stock entry with a dedicated resolver uses `stockAvailability`. Anything
* else, custom entries included, uses the registry's GENERIC resolver. That is the one a
* session spawn uses, and it understands the entry's declared binaries and search dirs.
*/
export function isCliEntryInstalled(entry: CliEntry, stockAvailability: Record<string, boolean>): boolean {
if (entry.kind === 'shell') return true;
const id = entry.id as string;
return Object.prototype.hasOwnProperty.call(stockAvailability, id)
? stockAvailability[id]
: isRegistryCliAvailable(id);
}
+35
View File
@@ -71,6 +71,15 @@ export interface ApprovalItem {
* and reach the user's other devices. See `acknowledge()`.
*/
acknowledgedAt?: number;
/**
* Why the item arrived already acknowledged, for display only: the inbox
* writes `watching 1 monitor` for a session that went quiet because work it
* started itself is still running. A human acknowledgement leaves this unset,
* so a card can say "quiet, watching 1 monitor" rather than implying somebody
* looked. ⚠️ Pane-derived text, so it is bounded at the source and must not
* reach the DOM as markup — see `watchingLabel()` in `session-activity.ts`.
*/
acknowledgedReason?: string;
/**
* Present only when the frame parsed confidently. Gates which digits the
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
@@ -93,6 +102,12 @@ interface NotePromptArgs {
toolSummary?: string;
message?: string;
cwd?: string;
/**
* What the session's pane says is still running in the background
* (`Session.watching`, e.g. `1 monitor`). An idle prompt from such a session
* opens ALREADY acknowledged: see `notePrompt()`.
*/
watching?: string | null;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
}
@@ -217,6 +232,22 @@ export class ApprovalInbox {
* Record a prompt for a session, superseding any previous item, and return
* the new item. Captures context immediately and once more after a short
* delay (see RECAPTURE_DELAY_MS).
*
* ⚠️ An idle prompt from a session that is WATCHING its own background work
* opens already acknowledged (`args.watching`). Claude Code ends the turn
* after arming a monitor or backgrounding a shell and then reports the pane
* idle a minute later, so the alert that follows asks a human to look at a
* session that wants nothing from them. Acknowledging is deliberately what
* happens here rather than skipping the item: the prompt is real and stays
* pending, answerable and available as Read My Mind context, and only the
* alert it would have armed is spent. A wrong label therefore costs a card
* that does not blink, never an alert that was never created.
*
* It re-arms by itself. The next idle prompt supersedes this item and builds
* a fresh one, so once the background work ends and the session goes quiet
* for an ordinary reason, that item carries no acknowledgement and alerts
* normally. Only `idle` is eligible: a permission or question dialog blocks
* the agent whatever else it started, so its alert must survive.
*/
notePrompt(args: NotePromptArgs): ApprovalItem {
this.resolveForSession(args.sessionId, 'superseded');
@@ -231,6 +262,10 @@ export class ApprovalInbox {
message: args.message,
cwd: args.cwd,
};
if (args.kind === 'idle' && args.watching) {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
}
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
+69 -1
View File
@@ -12,7 +12,8 @@
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
return { scanned, deleted };
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
*/
function canonicalDir(dir: string): string {
try {
return realpathSync(dir);
} catch {
return resolve(dir);
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
status?: string;
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
*
* Two kinds of sibling count as live:
*
* - a session in the server's map, unless it is itself being killed;
* - a persisted record whose status is not `stopped`. That covers a session
* detached with `killMux=false`, which leaves the server's map while its
* tmux pane keeps running, and a session whose detach is still in progress.
*
* A session being KILLED does not count. Without that exemption, killing two
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
* closing two panes on one tick) would have each defer to the other, and
* neither would remove the dir.
*
* Erring toward "in use" only costs a missed deletion, which the periodic
* sweep above ages out. A pinned record whose tmux session is gone keeps its
* status through boot pruning, so it holds the dir this way until unpinned.
*/
export function pasteImageDirInUseByOtherSession(input: {
live: Iterable<PasteImageDirUser>;
persisted: Iterable<PasteImageDirUser>;
closingId: string;
workingDir: string;
killing: ReadonlySet<string>;
}): boolean {
const target = canonicalDir(input.workingDir);
const matches = (user: PasteImageDirUser): boolean =>
user.id !== input.closingId &&
!input.killing.has(user.id) &&
!!user.workingDir &&
canonicalDir(user.workingDir) === target;
for (const user of input.live) {
if (matches(user)) return true;
}
for (const user of input.persisted) {
if (user.status === 'stopped') continue;
if (matches(user)) return true;
}
return false;
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
+740 -95
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more