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>
410 KiB
Architecture invariants
Implementation detail extracted from CLAUDE.md so that file stays small enough to load into every session cheaply. Most sections are the original paragraphs, verbatim, including the version history and PR references that explain why each rule exists; newer ones are written here first and summarized back into CLAUDE.md as a short rule plus a pointer.
CLAUDE.md keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
Network binding and instance isolation
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 (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'srewriteUrlso 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
onSendhook prepends the base to every root-absoluteLocationheader, covering all redirects. - (HTML)
renderIndexHtmlrewrites the shipped<base href="/">to the mount and injectswindow.__CODEMAN_BASE__— the template's asset refs are all RELATIVE so<base>handles them for free. - (frontend runtime URLs) root-absolute URLs ignore
<base>, soCodemanBase.url()(constants.js) is the route builder, applied transparently by afetchwrapper 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 relativestart_url/scope. - (web-tab proxy)
proxyPrefixFor(cap, basePath)is the single base-aware root that cascades to the injected<base>, root-absolute HTML rewrites, theruntimeUrlShim,Set-CookiePath andLocationrebasing — while the INGRESS parsers (capabilityFromProxyPath,resolveUpstreamUrl) stay base-agnostic becauserewriteUrlstrips the prefix before routing, andcapabilityFromReferer(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.
External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP): isExternalCliMode() in session.ts (mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek') gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ Work detection left this gate in #385 and is now per-CLI capabilities.workDetect data (promptGlyph + workingLine), because gating it on the mode left every Codex session reporting idle for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes require tmux — no direct PTY fallback — because secrets are injected via tmux setenv (socket-scoped ${this.tmux()} setenv, never on the spawn command line): OpenCode gets OPENCODE_CONFIG_CONTENT etc., Codex gets OPENAI_API_KEY/CODEX_API_KEY/CODEX_HOME (setCodexEnvVars), Gemini gets GEMINI_API_KEY/GOOGLE_API_KEY/GOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI etc. (setGeminiEnvVars, all in tmux-manager.ts). Codex specifics: command built by buildCodexCommand() (--model, resume <id>, --dangerously-bypass-approvals-and-sandbox from the codexConfig payload / codexDangerouslyBypassApprovals app setting; renderMode is schema-coerced to 'hybrid', the only supported mode). Gemini specifics: command built by buildGeminiCommand() (--skip-trust always, --approval-mode <default|auto_edit|yolo|plan> defaulting to yolo for parity with Claude's --dangerously-skip-permissions, --model, --resume from the geminiConfig payload); availability via GET /api/gemini/status — session/quick-start routes fail with OPERATION_FAILED + install hint (npm install -g @google/gemini-cli) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export COLORTERM=truecolor and unset NO_COLOR; opencode unsets COLORTERM. Terminal colour env under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins isAltScreenStripMode() (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/3J so scrollback survives). Codex availability via GET /api/codex/status. Antigravity specifics: command built by buildAntigravityCommand() (--model, --conversation <id> resume, --dangerously-skip-permissions from the antigravityConfig payload); availability via GET /api/antigravity/status — routes fail with OPERATION_FAILED + install hint (curl -fsSL https://antigravity.google/cli/install.sh | bash) when missing. Unlike the other three it is NOT an npm package (standalone binary, ~/.local/bin/agy), which is why docker/agent.Dockerfile installs it with its own --dir /usr/local/bin step rather than in the npm install -g line, and why it does NOT join isAltScreenStripMode(). Frontend: run-mode dropdown → runCodex()/runGemini() in session-ui.js ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ run*() MUST unwrap the {success,data} envelope ((await res.json()).data.available / data.data.sessionId) — reading the raw shape silently breaks the run. Tests: test/run-mode-ui.test.ts + test/gemini-mode.test.ts (vm-sandbox harness, no real DOM). Grok specifics: command built by buildGrokCommand() (--always-approve from grokConfig.alwaysApprove — grok's bypassPermissions permission mode, deny rules still apply; --model; --resume <id> / --continue, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via GET /api/grok/status, which carries version because the resolver version-probes candidates (grok has npm squatters, e.g. @vibe-kit/grok-cli — GROK_VERSION_REGEX is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → ~/.grok/bin, symlinked into ~/.local/bin), so docker/agent.Dockerfile installs it in its own step (copy to /usr/local/bin, drop root's ~/.grok in the same layer) and it stays OUT of isAltScreenStripMode() (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: GROK_* plus the vendor namespace XAI_* (XAI_API_KEY is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as GOOGLE_* for gemini). Docker cred seeding is per-file (auth.json, config.toml, pager.toml from ~/.grok — the dir also holds sessions/, memory/, and the ~160MB binary under downloads/). Grok tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts.
DeepSeek Harness (dsh) specifics — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
⚠️ The agent is a PROFILE, not the binary. dsh is a launcher over $DSH_HOME/profiles/<name> (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only web (browser UI), headless (one-shot) and base (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and isDeepSeekRunnable() (binary AND a pane-capable profile) is what the Run button gates on while isDeepSeekAvailable() (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. buildDeepSeekCommand() emits dsh --profile <name> [--resume [id]]; an absent profile resolves through resolveDefaultDeepSeekProfile(), which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses web/headless, which cannot drive a pane.
⚠️ The permission switch is an ENV VAR, not a flag. The harness has no --dangerously-skip-permissions equivalent; its sandbox/approval rows read DSH_PERMISSION_MODE with three presets (read-only / workspace-write / danger-full-access; measured from dsh --dump-default-config). It is exported via tmux setenv in _configureCliEnv(), never on the command line, and test/deepseek-mode.test.ts pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike CLAUDE_CODE_EFFORT_LEVEL (which hard-locks in-session /effort), the harness reads it with ?? as a boot-time DEFAULT, so it stays soft. Absent = workspace-write, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to workspace-write, NOT read-only, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ Clamping the config is only HALF the gate here, and this is the only CLI where that is true. Every sibling's bypass is a command-line flag, reachable only through the per-CLI config clampExternalCliBypassForOwner() already owns. DeepSeek's is an env var, DSH_* is an allowlisted envOverrides prefix (it must be — that is also how the harness's ordinary knobs are set), and applyEnvOverrides() runs AFTER _configureCliEnv() in tmux-manager, so envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'} sent on the SAME request as a clamped config lands last and wins. clampEnvOverridesForOwner() (session-routes.ts, exported as _clampEnvOverridesForOwner for tests) DROPS DSH_PERMISSION_MODE, DSH_HOME and DEEPSEEK_BASE_URL for a non-granted owner (the last because _configureCliEnv() forwards the SERVER's own DEEPSEEK_API_KEY into the pane, so a redirected base URL would send it to a foreign host) rather than rewriting them, since dropping falls through to what _configureCliEnv() exports, which is already the clamped value. DSH_HOME is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp.
⚠️ It is the only non-claude mode that passes hooksAvailableForMode(), and it earned that. The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with HERDR_ENV=1 + HERDR_BIN_PATH + HERDR_PANE_ID set it shells out <bin> pane report-agent <paneId> --state idle|working|blocked … on every state change and treats exit 0 as delivered. deepseek-status-shim.ts GENERATES a small script into the data dir (like self-update-runner.sh, so npm installs and git clones behave alike) and points HERDR_BIN_PATH at it; it forwards to POST /api/hook-event as idle→stop, blocked→permission_prompt, working→agent_working. So a dsh session gets real respawn triggers, real wait stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real herdr binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. ⚠️ For deepseek alone, hooksAvailableForMode() is a per-SESSION question, which is why it takes a HookCapabilityOptions second argument and every call site passes sessionHookOptions(session): deepSeekConfig.statusReporting: false skips the HERDR_* export, and that triple is the only reason a dsh session posts anything, so answering from the mode alone would accept until=stop on a session where nothing can ever send one — the infinite-wait-dressed-as-a-timeout the predicate exists to prevent. The option defaults permissive (!== false), so a call site that forgets it degrades to the old behaviour instead of 400ing a working session. ⚠️ Profile conformance is the LIMIT of what is knowable at request time: resolveDefaultDeepSeekProfile() deliberately treats an unrecognized profile as launchable, so a non-conforming TUI still answers true and still times out on an explicit stop — which is why the DEFAULT signal set keeps idle/exit. ⚠️ The predicate is not a stand-in for "is this a claude session", though it read like one while claude was the only true answer: Read My Mind (POST /api/sessions/:id/readmymind) and intent capture (captureIntentPrompt) read Claude's own transcript and were silently widened to deepseek by this change, so both compare mode === 'claude' directly and a static check in test/deepseek-mode.test.ts keeps them there.
⚠️ agent_working is a hook event with no Claude Code hook behind it (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins APPROVAL_RESOLVING_EVENTS; without it a dsh session's red alert would survive until the next stop, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here.
⚠️ Answers are READ FROM DISK for this mode, not scraped off the pane (src/deepseek-transcript.ts, behind GET /api/sessions/:id/last-response). dsh writes a structured JSONL transcript at $DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a last-response call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. (1) dsh appends one zstd FRAME per write, and Node's zlib zstd decoder — one-shot AND streaming — stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. zstdFrameRanges() walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (zstdSupported()), because it landed in Node 22.15 while the project floor is 22.0 and @types/node still does not declare it; without it the mode falls back to the pane exactly as before, which is why readDeepSeekLastResponse() distinguishes null ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). (2) Every turn also records a plugin-sourced user/message (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only source.kind === 'user' is a real prompt. (3) A turn that ends in reason.kind: 'error' is surfaced as Turn error: … (and a non-error early stop such as max-tokens as Turn ended: …) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. (4) Reply text is assembled per (turn, step): a finalized assistant/message wins, and the streamed assistant/chunk / text-chunks deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to '' at the </think> boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header cwd plus a ±60 s boot window against the Codeman session's createdAt, never by reproducing dsh's directory mangling (which already has two forms on disk, <uuid> and session-<uuid>) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The DSH_HOME override reaches the reader through the narrow Session.deepSeekHomeOverride getter rather than an envOverrides accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: test/deepseek-transcript.test.ts.
⚠️ This is also what makes dsh the one non-claude mode the codeman agent skill drives like claude (skills/codeman/preamble.sh, preamble 1.20.0): with a real end-of-turn signal AND a real transcript, spawn_workers alpha beta:deepseek is a mixed fleet in one call and sendwait/last_text need no per-mode variant. Two traps are handled in the preamble rather than left to the agent. Readiness is not the stop signal: the harness reports idle at BOOT roughly 300 ms before its composer paints (measured 2.26 s vs 2.56 s after spawn, twice), so a send-and-wait fired straight after quick-start resolves on the boot edge, reports a turn that never ran, and strands the prompt in a pane that was not yet accepting input — spawn_worker's dsh branch gates on the composer glyph (❯, overridable via DSH_READY_MARK) instead, after which the boot edge is spent and unobservable. And sendwait asks for wait:"stop,exit" rather than the wait:true default set, because that set also carries idle, which for any 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 that had three minutes left to run. The skill also sends deepSeekConfig.permissionMode: 'danger-full-access' for its own workers, matching what the Run button sends, because the harness default still asks and a worker parked on an approval row cannot finish a fan-out (the multi-user clamp still applies).
⚠️ The resolver needs the strictest identity probe of any CLI, because dsh is not merely a squattable npm name: Debian ships an unrelated dsh (dancer's shell, apt install dsh) that would answer a version probe convincingly. probeDeepSeekVersion() therefore checks dsh --help against DEEPSEEK_IDENTITY_REGEX (DeepSeek Harness) FIRST and only then reads a version, and test/deepseek-cli-resolver.test.ts pins both the rejection and the VITEST hermeticity gate with a real executable fixture. DEEPSEEK_VERSION_REGEX keeps the prerelease tail (0.1.1-rc.2), since truncating it would report an rc as a release; it is shared with the dsh dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
Model is NOT a session field: it is a composition entry in the profile's config tree (agent-default-model), configured in ~/.dsh/settings.yaml + cordis.patch.yml, so both create paths deliberately resolve no model for this mode. Env allowlist: DSH_* + DEEPSEEK_*; provider keys named by a settings-file apiKeyEnv stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds ~/.dsh per-file (.env, settings.yaml, cordis.patch.yml) and the image installs its OWN profile, because profiles/ is a per-profile node_modules tree — host-arch-specific and far too large to copy per container start. Stays OUT of isAltScreenStripMode() (third-party fullscreen TUI — the opencode case). ⚠️ classifyProfile() reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: readProfile() returns an empty bundle list for a package.json with no dsh.profile.bundles, which made the SHIPPED web/headless profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (STOCK_NON_INTERACTIVE_PROFILES), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose tui arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of intuition is not a rule anyone could predict. ⚠️ The generated shim is written temp + rename, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump SHIM_VERSION whenever SHIM_SOURCE changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via GET /api/deepseek/status, the widest per-CLI status shape (available/runnable/path/version/dshHome/defaultProfile/profiles); POST /api/deepseek/install-profile bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a detached: true process group (negative-pid SIGTERM→SIGKILL, as runGit() does in git-clone.ts). ⚠️ Node's own spawn timeout is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so close never fires and the request leaks forever. User guide: docs/deepseek-integration.md. Tests: test/deepseek-mode.test.ts, test/deepseek-cli-resolver.test.ts.
Pi specifics (#206, docs/pi-integration.md): command built by buildPiCommand() (--model — the only builder whose model regex admits : and /, for sonnet:high and openai/gpt-4o — plus --provider, --thinking, --session <id> / -c, and the TRI-STATE --approve/--no-approve). ⚠️ Pi has no permission prompts and no sandbox, so there is no --dangerously-skip-permissions analog and Codeman must not invent one; the privilege-shaped knob is approveProjectTrust, which makes pi LOAD AND EXECUTE repo-local .pi/extensions TypeScript and npm-install missing project packages. It therefore joins clampExternalCliBypassForOwner()'s materialize branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields --no-approve for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ --api-key is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays out of isAltScreenStripMode(): its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via /settings, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the PI_* env prefix was added; pi's ~34 provider keys share no prefix and ALLOWED_ENV_PREFIXES is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ pi is a short, GENERIC binary name, so unlike the sibling resolvers pi-cli-resolver.ts sanity-probes pi --version (cached, vitest-skipped) and requires semver-shaped output; GET /api/pi/status carries version on top of the sibling {available, path} shape so a misresolution is diagnosable. Local echo: pi lands on the 'buffer' overlay via the fallthrough in _updateLocalEchoState (pinned in test/local-echo-codex-gating.test.ts); if pi's live composer turns out to fight it the way codex's did, the fallback is one 'off' branch. Tests: test/pi-mode.test.ts, test/routes/external-cli-bypass-clamp.test.ts (first-ever coverage of the clamp).
OMP (omp, Oh My Pi) specifics (docs/omp-integration.md): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in ~/.omp config files (default tools.approvalMode: yolo), so buildOmpCommand() only ever emits --model/--resume <id>/--continue, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ That does NOT make the multi-user clamp a no-op: OMP_* is an allowlisted envOverrides prefix and admits OMP_AUTH_BROKER_URL/OMP_AUTH_BROKER_TOKEN (where omp resolves credentials from), both dropped for a non-granted owner in clampEnvOverridesForOwner() — the same shape as DEEPSEEK_BASE_URL — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). --continue alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — resolveAndClaimOmpSessionId() (src/utils/omp-session-resolver.ts) resolves and PINS the real id instead, verifying each candidate's own file header ({"type":"session","id",cwd"}, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (_pinOmpRespawnId(), called from _setupOrAttachMuxSession()'s dead-pane branch and reattachRemote()) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. src/omp-transcript.ts independently scans ~/.omp/agent/sessions/**/*.jsonl for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly PI_*, not OMP_* (PI_CONFIG_DIR, PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_SUBPROCESS_CMD, PI_SHELL_PREFIX), and PI_* is already allowlisted globally for pi — so a redirected PI_CONFIG_DIR silently moves the ~/.omp tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: appendResumeFlag()'s case 'omp' keys off the top-level resumeSessionId, which Docker panes never receive for omp (built from defaultDockerCommandForMode, with no ompConfig threaded through) — host-side history recovery and pinning work through the shared sessions/ mount, but --resume does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of isAltScreenStripMode() (narrow scrollback strip, alt-screen toggles only) and lands on the 'buffer' local-echo policy via the _updateLocalEchoState fallthrough, same as grok and pi. Resolver: omp-cli-resolver.ts version-probes like pi/grok (omp is a short, generic name) and requires omp/<semver>-shaped output; OMP_SEARCH_DIRS leads with ~/.local/bin (omp.sh's installer targets $HOME/.local/bin with no --dir override — verified against a real --no-cache Docker build, ~/.omp/bin was the wrong first guess). Tests: test/omp-mode.test.ts, test/omp-cli-resolver.test.ts, test/omp-session-resolver.test.ts, test/omp-fresh-run-no-resume.test.ts.
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 incl. byte-identity + 300ms-RTT).
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 shellescaped; 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 shellescaped 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 -fs 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,settings.json,stats-cache.json}, plus whole-dir seeds for ~/.gemini/~/.config/{gcloud,opencode}, plus, ONLY when CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ is 1 (enabledByEnv, read at container create), per-file seeds of the gh and az sign-ins (~/.config/gh/{hosts.yml,config.yml}, five sign-in files from ~/.azure, never its logs or extensions), which the agent image's system git credential helpers read for github.com / Azure DevOps (resolveDockerClaudeArtifacts/resolveDockerCredentialArtifacts in docker-hosts.ts). Bind mounts are physically excluded from docker commit, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY --env OPENAI_API_KEY (no =value); the SEALED profile is mountCredentials:false + network:none. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. Hardening on every create: --cap-drop ALL, --security-opt no-new-privileges, --pids-limit, --memory==--memory-swap, non-root via --user <hostUid>:0 (Linux, GID 0 for writable HOME) / --userns=keep-id (podman rootless) / baked uid (Docker Desktop), --pull=never, --init. Base image codeman/agent:base is BUILT LOCALLY from docker/agent.Dockerfile (node22 + tmux + every enabled npm CLI from the registry, since the CLI_NPM_PACKAGES build arg is generated from stock.ts and entries carrying agentImageLayer or no npmPackage get their own layers, see docs/docker-cases.md; OpenShift arbitrary-uid HOME, C.UTF-8 locale so tmux/Ink render real box-drawing glyphs; Codeman also sets LANG/LC_ALL at run time for containers built before that line) via scripts/build-agent-image.mjs OR auto-built on first use (1.4.1: ensureAgentBaseImage() in docker-hosts.ts; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, --pull=never stays absolute; build output streams over SSE docker:imageBuildStarted/imageBuildProgress/imageBuildComplete/imageBuildFailed, and quick-create returns imageBuilding:true while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (checkDockerTmuxAvailable), never a silent bare-exec fallback. Hooks + model: the workspace-scaffolding block DOES run for docker (writes .claude/settings.local.json + the CLAUDE.md scaffold into the real host dir), so modelOverride works via settings.local.json — it is a QuickStartSchema field applied for local AND docker quick-starts (updateCaseModel), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); effort/envOverrides/codexConfig/geminiConfig/openCodeConfig stay rejected. In-container hook curls hit containerApiUrl(process.env.CODEMAN_API_URL, engine) (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both host.docker.internal/host.containers.internal (DOCKER_HOST_GATEWAY_ALIASES in network-auth-policy.ts). ⚠️ On a loopback-only bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when CODEMAN_DOCKER_BRIDGE_HOOKS=1 — an opt-in SECOND listener on the docker bridge gateway (_startDockerBridgeHooksListener in server.ts; gateway auto-detected via detectDockerBridgeGateway, or set CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set CLAUDE_CODE_TMPDIR keeps claude launching regardless of workspace path. SessionState.docker/MuxSession.docker round-trip through recovery. Every docker IO path is IS_TEST_MODE (VITEST) no-op'd; the pure builders are unit-tested. Export/import (src/docker-export.ts): full-image (docker commit + save | gzip + workspace tar + manifest) or workspace-only → one portable ~/.codeman/docker-exports/<case>-<ts>.codeman-container.tgz; import validates per-member sha256, traversal-guards the workspace tar, docker loads + quarantine-retags the image (codeman/imported-<case>:<ts>, never overwriting a local tag); a saveImageToTar stream pipeline avoids truncation. GPU passthrough (gpus → --gpus, needs the NVIDIA container toolkit) and elastic disk (no --storage-opt cap, so container storage grows with data). SSE docker:exportComplete/exportFailed/importComplete (both registries). UI in session-ui.js: Create Case Docker tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short (docker) case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs w<n>-<case> via the shared _nextCaseSessionStartNumber() so all tabs follow one naming convention. Adopting an already-running container (DockerCase.owned === false, POST /api/cases/docker-adopt + the read-only POST /api/docker-cases/adopt-preflight, GET /api/docker-hosts/:hostId/containers, POST /api/docker-cases/browse): the mirror of remote-SSH's owned:false attach. For an adopted container the launch chain only LOOKS and then execs — no image gate (the image is theirs), no create, and above all no start, since starting a container we do not own is precisely the mutation adoption promises never to perform; a missing or stopped container fails closed with an actionable message. Credential seeding is skipped too (those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do), so its CLIs must already be authenticated inside it. Absent owned = owned, so every pre-existing case is byte-identical. ⚠️ The guarantee is NEGATIVE, so it cannot be observed by using the feature — only by asserting the mutating verbs are absent — and it is therefore enforced at four deliberately independent layers: buildDockerStopCommand/buildDockerRemoveCommand throw during pure STRING CONSTRUCTION (no shape of caller bug can produce a docker stop/rm for a container we do not own), removeDockerContainer refuses again at the lowest layer, checkDockerConfigDrift reports "none" (an adopted container carries no codeman.confighash label, so a real comparison would always report drift and the launch gate would 409 forever, offering a recreate we may not perform), and the orphan reaper skips it through a check independent of the two conditions that already cover it. ⚠️ The export path is the one place that still touched the container and both halves had to be closed: a full export docker commits it (refused for an adopted case — it packages someone else's container, with their logins, into a bundle Codeman hands out) and even a workspace-only export docker paused it first for snapshot consistency (skipped: the freeze stops the owner's processes for as long as the tar takes). ⚠️ owned is applied AFTER the config hash; dockerConfigHash takes an explicit field list, so ownership can never shift an existing case's hash and mass-trip the drift gate, whose only remedy is "recreate the container". ⚠️ The container workdir is verified INSIDE the container: it defaults to hostWorkspacePath for an OWNED case only because the create-time bind mount puts the host directory at that exact path, and adoption mounts nothing, so the two are independent facts — without the check docker exec --workdir <missing> fails with an OCI chdir error the pane surfaces as a bare execvp failed. ⚠️ Run-mode availability comes from the CONTAINER (availableModes), live-probed rather than trusted from attach time: gating the dropdown on host CLI availability (#201) is right for local sessions and wrong here. The probe modes and the BINARY each mode looks for both come from the CLI registry (enabledCliIds() / discovery.binaries[0]), never a local table — a hand-written list silently froze once already, missing omp and hiding that mode on every docker case; antigravity ships as agy and deepseek as dsh, so a mode-name probe reports both missing on a container that has them, and a mode with no binary (shell) is reported available without a lookup. ⚠️ A FAILED probe means opposite things per ownership. For an adopted case it is a real fault (only the user can start that container). For an owned case it is the NORMAL state before the first session — the launch chain creates the container on demand — so recording it as an error hid every agent mode on every freshly linked Docker case behind "start it yourself first", for a container Codeman was about to create itself; CaseInfo.docker.owned exists on the wire so the frontend can tell the two apart. ⚠️ Claude launches WITHOUT --dangerously-skip-permissions when the container's exec user is root: Claude Code refuses the flag as root ("cannot be used with root/sudo privileges", still true in 2.1.261) and the refusal is visible only inside the container, so the pane just dies. Our base image runs a non-root user and never hits it; an adopted container's user belongs to its owner and is frequently root. Which flag to drop is a per-CLI fact, so it is overlays.docker.rootCommand in the registry rather than an id branch. ⚠️ Admin-only in multi-user mode, unlike docker-link right next to it: linking creates OUR container, whose sole bind mount isWorkingDirAllowed has already confined to the caller's space, while an adopted container's mounts are whatever its owner gave it — one mounting / hands the adopter a shell over the whole host, defeating exactly the workspace scoping that mode exists to enforce. The container listing and the in-container directory browser are gated with it (both are machine-level reads over containers belonging to anyone); the preflight is NOT, because the run menu probes it for every docker case, so it admits a non-admin only for a container already linked to a case they can access. Tests: test/docker-adopted-container.test.ts.
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.
⚠️ Three capability fields carry a REGEX from config (discovery.version.regex, capabilities.workDetect.workingLine and capabilities.workDetect.watchingLine) and all three 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
Input: session.writeViaMux() for programmatic/curl input — tmux send-keys -l (literal) + send-keys Enter. Single-line only (fire-and-once). Interactive browser input goes through a durable exactly-once layer: each frame carries a stable clientId + monotonic per-session seq, persisted to localStorage until the server ACKs ({t:'ia',seq} over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as {t:'ia',seq,dup:true,last} so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (docs/reliable-input-delivery.md). WS resilience (#149): the upgrade URL carries cid = clientId + ':' + perTabNonce, and ws-connection-registry.ts supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare clientId for seq dedup); reconnects back off exponentially (attempts preserved across _connectWs), and the header connection chip renders from a real _wsState lifecycle (connecting/connected/fallback/reconnecting/disconnected).
Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix (#255): ALLOWED_ENV_PREFIXES in src/web/schemas.ts carries the CLI-namespace prefixes, and ALLOWED_ENV_KEYS carries exact keys (currently only CLAUDE_CONFIG_DIR). CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. ~/.claude-clients/acme via envOverrides and run /login there once against the client's account. The exact match matters: CLAUDE_ as a prefix would open every future Claude CLI variable unreviewed, and near-misses (CLAUDE_CONFIG_DIR_EXTRA) stay rejected (test/env-overrides-schema.test.ts). No new security boundary is crossed: sessions already run as the server's OS account, and applyEnvOverrides() shellescapes values into socket-scoped tmux setenv. Two carry rules: (1) the key must survive getEnvOverridesForPersist() in session.ts (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); (2) ⚠️ a relocated config dir writes transcripts outside homedir()/.claude/projects, which subagent-watcher.ts, workflow-run-watcher.ts, the response-viewer routes and Read My Mind capture all hardcode — those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (ln -s ~/.claude/projects <configDir>/projects), keeping credentials separate while the watchers keep working.
⚠️ As of the custom-model endpoint feature, CLAUDE_CONFIG_DIR is ALSO admin-only in multi-user mode, which is a change to the above rather than a restatement of it. It joined claude's privilegedEnvKeys (stock.ts) alongside CLAUDE_CODE_MAX_CONTEXT_TOKENS, because the rule that every traffic-redirecting var that feature can inject must be listed there is worth keeping literally true. privilegedEnvKeys has exactly one consumer, ownerClampedEnvKeys() in src/session-env-clamp.ts, which feeds the generic envOverrides clamp on POST /api/sessions, POST /api/quick-start and reboot-restore. So for a non-granted owner two things now follow: the key cannot be set through envOverrides at all, and an ALREADY-PERSISTED one is stripped on reboot-restore, which silently moves that session back to the default Claude account. That second consequence is the one to watch, since it turns a working per-client setup into a wrong-account one across a host reboot with no error anywhere. session-env-clamp.ts's own fileoverview used to state the opposite invariant (that claude's privileged keys are the five ANTHROPIC_* names, so a persisted record cannot carry a clamped key) and was corrected when this landed; a claude clamp test now pins the behaviour next to the deepseek and omp ones.
Agent wait primitives
Agent wait primitives (GET /api/sessions/:id/wait, GET /api/sessions/:id/wait-output, and the wait/waitTimeout fields on POST /api/sessions/:id/input): bounded long-polls that let an agent driving Codeman from a shell tool block until something happens. They exist because SSE was the only "tell me when" channel Codeman had, and a curl-driven caller cannot practically hold a stream and parse events inline. The blocking core is src/web/session-wait-registry.ts (no IO, no Session reference, so it unit-tests in isolation), bounds live in src/config/agent-wait.ts, and the wiring is three notifySignal() calls next to existing broadcasts (session-listener-wiring.ts for working/idle/exit, hook-event-routes.ts for stop/blocked) plus notifyOutput() riding the already-attached terminal listener. Design: docs/agent-control-plan.md §3; wire contract: docs/api-reference.md.
Ordering rules, both load-bearing and both invisible to a reader of either side alone. ⚠️ exit-before-cancel: _doCleanupSession in server.ts must call sessionWaits.notifySignal(id, 'exit') BEFORE sessionWaits.cancelAll(id), because that method detaches the session's listeners before session.stop(), so on a delete the PTY's own exit event never reaches the registry and an until=exit caller would get a bare ended: true instead of the signal it asked for. Found by live-testing the delete path, not by the unit tests. ⚠️ registered-before-write: the send-and-wait path on POST .../input registers the waiter BEFORE writing to the PTY, and that ordering is the entire reason the combined endpoint exists rather than documenting "POST, then GET .../wait": between the write and the session flipping to working there is a window in which a separate wait sees the session still idle and instantly reports the PREVIOUS turn as this turn's answer. Two consequences hang off it: useMux delivery is awaited on that path (the response is staying open anyway, so a writeViaMux failure becomes observable for the first time) while the non-wait path keeps its fire-and-forget shape byte for byte, and because shouldApplyInput() MUTATES (it records the seq) before registration, a registration that fails on a full pool must forgetInputSeq() before returning, or the caller's retry is rejected as a duplicate and the input is lost by the very mechanism reliable delivery exists for.
A timeout is a 200, deliberately. {"timedOut": true, "signal": null} with HTTP 200 is the long-poll succeeding at answering "did this happen within N ms?" with "no". The documented client pattern is a loop over short waits (DEFAULT_WAIT_MS is 60s precisely because prod is reached through tailscale serve and users run cloudflared, both of which cut idle connections), and turning every poll boundary into a 4xx would make that loop indistinguishable from a real failure. The alternatives are all worse: 408 is auto-retried by several clients and proxies, silently doubling the polling load; 504 is what a genuine tunnel failure looks like, so reusing it destroys the caller's ability to tell the two apart; 204 cannot carry waitedMs/status/limitPaused and breaks the uniform envelope the versioning policy makes a stable promise. Errors are reserved for INVALID_INPUT (400), NOT_FOUND (404, also the multi-user ownership answer via findSessionOrFail), SESSION_BUSY (409, this session's cap) and RATE_LIMITED (429, the per-owner or process-wide cap: a global cap reported as SESSION_BUSY tells the caller to switch sessions, which cannot help). ⚠️ The clamp is silent, so the EFFECTIVE timeout is echoed back as wait.timeoutMs: a caller that asked for 30 minutes, got the 600s ceiling, and could not see it would read the timeout as "the worker is wedged" and kill a session that was working fine. All three endpoints nest the result under data.wait for the same reason, so one client helper works against any of them instead of an agent's is_done() reading undefined off the shape it did not expect.
Matching is literal, and that is a language constraint, not a missing feature. search-service.ts already avoids regex so there is no ReDoS surface, and this endpoint is more exposed still: the pattern is caller-supplied and the input is a live stream. herdr can offer --regex on its equivalent because Rust's regex crate is linear-time with no backtracking; JavaScript's RegExp backtracks, so the same feature here is a denial-of-service primitive. A regex parameter is therefore REJECTED with a 400 rather than ignored, since an agent that assumed otherwise would silently wait on the wrong thing. match is capped at 200 chars (MAX_MATCH_LENGTH), which is also what keeps the per-waiter carry buffer small: a match can straddle two PTY chunks, so each waiter carries match.length - 1 characters of the previous chunk and tests carry + chunk. ⚠️ from=now does not mean "printed after you asked": tmux repaints the visible screen on attach, on resize, and on any TUI redraw, and a repaint arrives as ordinary terminal data, so text already on screen can satisfy a fresh wait (observed live: a marker echoed a minute earlier matched instantly). This is inherent to running the agent under a multiplexer and is not fixable in the registry, so the contract is a marker unique per call (echo DONE_$RANDOM), never a generic one like BUILD OK, and every recipe must show that. ⚠️ There is exactly ONE definition of the matched stream, normalizeForMatch(): stripAnsi() (CSI, OSC, ESC =/ESC >) plus ANSI_ESCAPE_RESIDUE for what that helper leaves behind — above all the ESC ( B charset switch a stock bash prompt emits on every line, which in the first build survived into the matched text and made match=tnode: silently fail against a prompt that plainly renders tnode:. The haystack, the carry and the snippet window all derive from that one function, so matching and the snippet cannot drift apart again. ⚠️ splitTrailingEscape() holds back a partial escape at a chunk boundary until its tail arrives; its INCOMPLETE_ANSI_TAIL pattern must stay in lockstep with ANSI_ESCAPE_RESIDUE (a chunk cut between the ( and the B is otherwise a fresh way to smuggle an escape into the haystack), and it is deliberately non-global (the repo-wide lastIndex hazard). With the carry, a match may straddle PTY chunks: printf STRAD; sleep 1; printf DLEQQ is matchable as STRADDLEQQ (measured live, both from modes). ⚠️ The matcher still sees the byte stream, not the rendered pane (GET .../terminal is a tmux capture, source:'mux-visible'): linear output agrees once escapes are stripped, but Claude Code positions words with cursor moves instead of spaces, so TUI text can arrive space-less (Quicksafetycheck:Isthis... — measured; some phrases keep their spaces depending on how the TUI drew them), which is why the documented advice remains ONE short space-free token the caller printed itself. The returned snippet is a RENDERING of the matched window, not a quotation: SNIPPET_CONTROL_BYTES drops bare control bytes that carry no ESC (BEL, NUL, backspace — normalizeForMatch removes escape SEQUENCES only) and blank runs are collapsed, because the consumer is an agent piping it through jq into its OWN pane, where a worker's raw bytes could otherwise reset or garble the orchestrator's display.
Lifetime discipline, per the 24-hour-session rules. Every waiter owns exactly one timer, cleared on resolve; per-session waiter sets are deleted when they empty; cancelAll() runs on session teardown and cancelEverything() in stop(). ⚠️ Waiter timers are deliberately NOT unref'd, the opposite of the usual advice: an unref'd timer would let the process exit mid-wait and strand the HTTP response, so shutdown resolves waiters explicitly instead. All three routes also release the waiter when the client disconnects (abortOnClientHangUp() in session-routes.ts), which the caps make load-bearing rather than tidy: the documented loop-over-short-waits pattern is naturally written as curl --max-time 30 ".../wait?timeout=60000", and without it every iteration abandons a waiter that lives out its full timeout, so the seventeenth call gets a 409 for a session nobody else is waiting on. ⚠️ That listener goes on reply.raw, guarded by writableFinished, NOT on req.raw (the obvious choice, and the one the SSE route in server.ts can afford because it only ever serves a GET). req.raw emits close as soon as the REQUEST BODY has finished streaming, which on a POST happens before the handler blocks: measured at +1ms with aborted: false, indistinguishable from a real hang-up, so wiring it there cancels every send-and-wait instantly and silently kills the feature, while GET keeps working because a GET has no body to finish. reply.raw emits close both on a completed response and on a dead socket, and writableFinished is the only thing that separates them, so the guard is load-bearing rather than defensive. ⚠️ app.inject() never emits close at all, so none of this is observable in a route test: the regression test has to bind a real port. MAX_WAITERS_PER_SESSION (16) is a COMBINED signal-plus-output budget (waiterCount() sums both maps), not 16 of each; MAX_WAITERS_PER_OWNER (48) applies only when an owner is passed, so single-user mode behaves exactly as before; MAX_WAITERS_TOTAL (128) mirrors MAX_SSE_CLIENTS in map-limits.ts, since each pending waiter costs an open HTTP response plus a timer. Capacity is asserted BEFORE the expensive work on both sides: waitForOutput() checks before the initialText scan, and the wait-output route checks before reading session.terminalBuffer, whose getter joins the whole 32MB accumulator, so a request that is going to be rejected never pays for a buffer materialization. from=buffer scans only the tail (MAX_BUFFER_SCAN_BYTES, 256KB) because the question it answers is "did this appear recently", not "ever", and the tail is continuous with the live stream (append() and emit('terminal') receive the same bytes).
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: test/session-wait-registry.test.ts, test/routes/session-wait-routes.test.ts, test/routes/session-wait-output-routes.test.ts, test/routes/session-input-wait.test.ts.
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.
Plan-usage chip (statusLine telemetry)
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 CronJobs): 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.
Unified session list and Session Manager
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.
Resolved, not trusted (resolveParentSessionId(), route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's $CODEMAN_SESSION_ID both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (canAccessOwned) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ Everything unresolvable is DROPPED, never a 400: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides toState() into session_created / session_updated, so there is no new SSE event, and server.ts's recovery path restores it so lineage survives a restart.
Rendering is an additional LAYER, not a second pass (session-lineage.js, loadorder 15.6): _updateConnectionLinesImmediate() (subagent-windows.js) calls _appendLineageConnectionLines(svg, rects) at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same tab:<id> rect cache. Geometry is pure and unit-tested in computeLineagePath() (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ A wrapped strip used to get its own shape, and that shape was the bug (fixed 2026-08-14): tabs-two-rows/tabs-auto-wrap put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, 4 4 → 5 5 dashes at -20, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
⚠️ Desktop only, for a z-index reason: the overlay is z-index: 999 and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header position: fixed; z-index: 1200 and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
⚠️ 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.
Three owners, one setter. SessionState.nameSource is placeholder | auto | manual. The constructor infers a missing value from the name (isGeneratedSessionName() = w<n>-<case> / s<n>-<case>, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The name setter is the manual path and the ONLY thing that produces manual after construction; applyAutoName() is the only thing that produces auto, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (/clear, ! npm test, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
The origin gate defaults to "system". write() / writeViaMux() take SessionWriteOptions.fromUser; only the browser WS path and POST /api/sessions/:id/input set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's /compact, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. _lastSubmitAt is still stamped for every write; only the tracker feed is gated. The shell gate is getCli(mode)?.capabilities.startMode !== 'shell', a capability rather than an id check (the no-id-branching guard), and the send-key route feeds trackUserInput() by hand because its tmux send-keys -H line feed never passes through the session.
The tracker is a best-effort transcript with explicit per-key rules, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), \n and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
Title and composition. deriveAutoSessionName() strips CSI/control bytes, refuses slash commands by the /^\/[a-z][a-z0-9_:-]*(\s|$)/i shape (a path has a second slash where the whitespace should be, so /home/me/notes.txt what is this is a prompt) and ! shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. composeAutoSessionName() prepends the placeholder (w3-myapp: fix the login redirect) and fits the result into MAX_SESSION_NAME_LENGTH in UTF-16 units, the unit the rename route caps in.
Opt-in, and the listener orders its checks for cost. The prompt lands in the tab name, mux-sessions.json, every session:updated broadcast, the TUI, both home screens and /api/search (which matches on sessionName), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so autoNameSessions is synced and default OFF, like agentSkillEnabled, approvalsInboxEnabled and readMyMindEnabled. The listener checks nameSource and derives the title BEFORE reading settings.json, so an already-named session costs nothing per prompt.
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. 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 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 border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ The move is RELATIVE — up rows - 1 - cursor_y, then \r, then right cursor_x — never CUP. \x1b[<row>;<col>H numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals pane_height, and nothing guarantees that: resizeWindow issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and _onSessionNeedsRefresh sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: no transform that can DELETE A LINE may run over a full-history capture, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by \n+$, stripInkRedrawBloat, the CLAUDE_BANNER_PATTERN trim that cuts everything above the banner, and LEADING_WHITESPACE_PATTERN — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ Those skips key on isFullCapture, meaning a capture actually came back — never on ?full=1 alone. When captureActivePaneBuffer returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to session.terminalBuffer, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (hasVisibleContent) returns '', because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where _replayWouldShrinkBuffer cannot see it. ⚠️ "One line per screen row" holds only where no row was hard-wrapped: -J joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption _estimateReplayRows already documents. Tests: test/tmux-capture-full-history.test.ts covers the cursor move, the trim pairing and hasVisibleContent; test/routes/session-routes.test.ts covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ The re-pull must never DOWNGRADE the buffer (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (history_size≈0 measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — _resetTerminalForReplay() + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). _replayWouldShrinkBuffer() (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, capture-pane -J re-wrapping accounted for — and the pull is skipped when that is more than one screen short of buffer.active.length. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins _fullHistoryRepullUseless, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: test/tmux-capture-full-history.test.ts, test/tmux-scrollback-eol.test.ts, test/terminal-scroll-routing.test.ts, test/terminal-flush-budget.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.
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.
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.
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.
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.
The wheel listener is CAPTURE-phase and Codeman owns the scroll (#205 follow-up, measured on the live instance): xterm's viewport is a vscode-style ScrollableElement that consumes wheel events itself (preventDefault + stopPropagation) whenever it believes a scrollbar exists, ignores attachCustomWheelEventHandler, and goes DEAF after terminal.reset() — a tab switch or full-history replay leaves its scroll dimensions stale, after which wheel events neither scroll nor propagate reliably. A bubble-phase container listener therefore never fired once local scrollback existed (forwarding, deltaMode and the top-of-buffer re-pull all silently dead exactly on sessions WITH history), and after a tab switch nothing scrolled at all ("works at first, breaks after a tab switch"). The container wheel listener is {capture: true}, stops propagation, and scrolls locally via buffer-level terminal.scrollLines() (immune to the stale scroller). ⚠️ Two cases are deliberately passed through untouched, in this order BEFORE preventDefault: mouseTrackingMode !== 'none' (xterm's encoder forwards the wheel to the PTY — htop/vim with mouse on) and buffer.active.type === 'alternate' (direct-PTY vim/less: xterm's alt-scroll converts the wheel to cursor keys). Do not "simplify" this back to a bubble listener or re-delegate local scrolling to xterm's viewport. E2E guard: the reload → tab-switch → wheel matrix in the #205 verification scripts.
Run launch synchronization
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-manager.test.ts, test/session-pane-exit-ui.test.ts.
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, sostatus ?? 0would 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 reachesapplyPaneExits(), 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()andrestartCli()). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead untilclearPaneExitForNewPane()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 asexited (0)instead. An attach to an already running pane stamps it too, so an/exitwithin 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 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.
⚠️ 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
Attachments (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in file-routes.ts. Registry (attachment-registry.ts): an in-memory map of a stable attachmentId → an absolute, realpath-resolved, extension-allowlisted file path, so browser requests (GET /api/sessions/:id/attachments/:attachmentId/raw) never carry arbitrary absolute paths; POST /api/sessions/:id/attachments registers one. Magic links (attachment-magic.ts): parses codeman://attach?... out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is force-confined to the session workspace (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the attachment:detected SSE event. Security gate is an extension allowlist (isSupportedAttachmentExtension, in the registry/magic modules), not a blocklist; a separate path layer (config/attachment-guard.ts) confines reads to the workspace (attachmentConfineToWorkspace) and blocks sensitive trees (/root, /etc). Previews + thumbnails (COD-38): :attachmentId/preview + :attachmentId/thumbnail (and the workspace-file equivalents file-preview/file-thumbnail) render Office docs/PDFs via external converters (pdftoppm / LibreOffice soffice / Word-COM powershell); document-preview-cache.ts is a shared disk cache (de-dups identical in-flight inputs), document-thumbnailer.ts does best-effort first-page images, and document-conversion-limiter.ts is a global converter-spawn concurrency cap (runWithConversionLimit) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. History drawer (COD-39): session-attachment-history.ts tracks the last ATTACHMENT_HISTORY_LIMIT (100) attachments per session (Session._attachmentHistory, persisted via SessionState.attachmentHistory, replayed so externals re-register on reconnect); GET /api/sessions/:id/attachments is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see mobile-header-buttons-policy test). Session-local files keep using the existing workspace-scoped file-routes paths; the registry is only for explicit live externals. Codex generated artifacts (COD-166/#150, generated-artifact-attachments.ts): codex-mode sessions ALSO scan (ANSI-stripped) output for Saved to: file:///… lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the realpath-resolved path against os.homedir()-anchored ~/.codex marker dirs (symlink escapes fall back to force-confinement); gated to mode === 'codex' only (source is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
File-path links (terminal + response viewer)
A file path an agent prints is a link on both surfaces it can appear on, and clicking it opens the file-preview overlay. Three things make that work and each has bitten:
One pattern, two consumers. FILE_PATH_LINK_PATTERN / absoluteFilePathPattern() live in constants.js; the xterm link provider (registerFilePathLinkProvider, terminal-ui.js) and the response viewer's _linkifyFilePaths() (app.js) both build a fresh instance from it. ⚠️ Fresh per call, never one shared object: lastIndex is per-object state on a /g regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (3/4) or a date can't match and trailing punctuation stays out. Roots include Users and mnt, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in test/link-provider-regex.test.ts.
The chat linkifier walks text nodes. _linkifyFilePaths() builds anchors with createElement/textContent on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an <a> are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. test/response-viewer-file-links.test.ts pins both properties.
Out-of-workspace paths go through the attachment routes, not the file routes. file-content/file-raw resolve against workingDir and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a /tmp capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. openFilePreview() now detects the case (_isExternalPreviewPath, a string compare for ROUTING only; the real decision stays server-side) and registers the path via POST /api/sessions/:id/attachments first, rendering by id. ⚠️ That registration passes notify: false, which suppresses ONLY the attachment:detected broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the explicit, Origin-guarded registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside SUPPORTED_ATTACHMENT_EXTENSIONS (.svg, .bmp) is refused with a message naming what IS previewable, rather than the registry's own policy term.
⚠️ The terminal routes an out-of-workspace path to the preview, not the log viewer. The log viewer spawns tail -f and allows only the workspace, /var/log and ~/logs, so an external .log/.json/code path answered Path must be within working directory or allowed log directories while the SAME path clicked in the response viewer previewed fine. activate() now checks _isExternalPreviewPath alongside previewsInFileViewer. In-workspace text keeps the tail viewer, which is the point of it (live follow); nothing widened file-stream-manager's allowlist, so no tail -f is spawned on an arbitrary host path.
Text reuses the edit-mode allowlist; markup stays download-only. TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (config/file-editing.ts) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already cat any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, /root and /etc trees, realpath first). ⚠️ Two consequences had to be handled at the same time: ~/.codeman*/state.json joined isSensitivePath (it persists SessionState.envOverrides, and the env allowlist admits key-shaped names like GEMINI_API_KEY, so it can hold a live credential), and html/htm joined svg in serveRawFile's download-only branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert text/plain; charset=utf-8 + nosniff, matching the picker. The by-id text preview is bounded like the workspace one: a Range request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
Media is single-sourced across the two preview paths. VIDEO_ATTACHMENT_EXTENSIONS / AUDIO_ATTACHMENT_EXTENSIONS live in attachment-registry.ts and are imported by file-content's media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to /tmp was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a real MIME entry in MIME_TYPES (a <video> refuses to decode application/octet-stream, which presents as a player that renders and then does nothing), and the range-aware body (serveRawFile → sendFileBody) that makes the scrub bar work. getAttachmentType() returns the video/audio members of AttachmentDetectedType for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since generateFirstPageThumbnail has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (png/pdf/docx/pptx), so this does not start popping cards for every video an agent writes.
⚠️ The preview overlay must outrank the panel that launched it. .file-preview-overlay sits at z-index: 5100, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay behind the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
Filesystem path picker
Filesystem path picker (Link Existing "Browse" button + the extended mobile keyboard's 📁 Path key): a lazy one-directory-at-a-time browser over GET /api/filesystem/browse, with GET /api/filesystem/preview serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then /mnt/d, then the first root), hides dot entries, and inserts the chosen path without Enter so the prompt is not submitted. The companion ⌫ All key clears only the current unsent prompt buffer and must never emit the agent's /clear command.
⚠️ This is a second file-serving surface, so it carries the same confinement burden as Attachments and does not inherit it automatically. Traversal is allowlisted to Home, CASES_DIR, /mnt/d, or extra roots explicitly configured via CODEMAN_FILE_PICKER_ROOTS; sensitive trees are blocked and symlink escapes are rejected after realpath resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. preview reuses the shared conversion cache and the global document-conversion-limiter, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert text/plain (never text/html, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
⚠️ Ownership scoping is also not inherited, and both endpoints must do it themselves. Two separate holes shipped in the original version and are now regression-guarded in test/routes/file-routes.test.ts:
- The optional
sessionIdparam adds that session'sworkingDiras a "Current Folder" root. It is looked up directly offctx.sessions/ctx.storerather than throughfindSessionOrFail, so thecanAccessOwnedcheck has to be written out by hand. Without it a multi-user caller pins another user's working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists. HomeandCASES_DIRwere unconditional roots. Per-user spaces live at<USER_SPACES_DIR>/<username>, which is insidehomedir(), so aHomeroot alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets onlyMy Space(their ownuserSpacePath) plus anything inCODEMAN_FILE_PICKER_ROOTS;/mnt/dis dropped too, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins and single-user mode keep the host-wide set unchanged.
The general rule: any new endpoint that turns a caller-supplied sessionId into a filesystem path is an ownership boundary, whether or not it goes through findSessionOrFail.
File Viewer edit mode
File Viewer edit mode (issue #212, design in docs/file-viewer-edit-plan.md): the file-preview overlay can edit workspace text files in place — GET /api/sessions/:id/file-content?edit=1 (read-for-edit) + PUT /api/sessions/:id/file-content (save), policy in src/config/file-editing.ts, UI in panels-ui.js. This is the only file surface that writes, so it carries every rule the read surfaces have plus its own:
- Confinement is the read path's, plus write-only gates.
findSessionOrFail(ownership) →validateSessionFilePath(realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) →.git/subtree deny (403 —.git/hooks/*is code execution) → extension allowlist (400;svgandenvdeliberately excluded). ⚠️ There is noO_CREATanywhere in the handler — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface. - A truncated buffer must never become an edit buffer. The plain preview truncates to
lines(default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best).edit=1therefore never truncates — it 413s overMAX_EDITABLE_BYTES(512KB) instead — and the frontend always re-fetches withedit=1before swapping in the textarea, even though the preview already holds content. - Concurrency is optimistic by content hash, not mtime. The client echoes the sha256 it loaded (
baseHash); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetchesedit=1for fresh state) unlessforce:true. mtime alone is wrong: agents rewrite files within one timestamp tick. - Writes are
wxtemp +fchmod+fsync+renamein the target's directory.wxcannot follow a pre-existing symlink andrename()replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window;fchmodbecauseopen()'s mode argument is masked by the umask; a symlink whose target is inside the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content. - Corruption guards: NUL-sniff + UTF-8 round-trip compare (
Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a<textarea>normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff). - Two size caps on the wire: the Zod
.max()counts UTF-16 code units (coarse pre-filter, 400) while the handler'sBuffer.byteLengthcheck enforces the real byte cap (413); the route setsbodyLimit: 4MBbecause JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths throw structured{statusCode, body}errors (throwFileEditError) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all.
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).
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 agit-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 (unlikeremote-hosts.ts, which does build a shell line and mustshellescape). The Zod schema deliberately only length-boundsrepository— a weaker regex duplicate ofparseGitRepositoryUrlwould 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, emptyGIT_ASKPASS/SSH_ASKPASS+SSH_ASKPASS_REQUIRE=never+ emptyDISPLAY,GCM_INTERACTIVE=never, andssh -oBatchMode=yes.HOME/PATHare 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 auser:password@URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run withgit -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-inCODEMAN_INSTALL_GH/CODEMAN_INSTALL_AZargs it configuresgh(github.com) and anaz-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) becausegit clonefans out intogit-remote-https/index-packchildren that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL;ls-remotestdout 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 asdocument-conversion-limiter.ts. - Repository contents beat scaffolding. An existing
CLAUDE.mdis kept (a generated one is written only when absent) and hooks are MERGED into whatever.claude/settings.local.jsonthe repo shipped. A repo that ships its own.claude/settings*.jsonis reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there. - Failure leaves nothing behind. The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ Error detail comes from the LAST diagnostic line, not the first.
git cloneopens withCloning into '<dest>'…, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote).NOT_FOUNDwording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated. - Multi-user: NOT admin-gated, unlike
/api/cases/link— it writes only inside the caller's ownresolveCasesDir. The exception is alocal-transport source (an absolute path orfile://), which is admin-only there because per-user spaces live inside one$HOMEand a local clone would read straight through that boundary. - UI (
case-clonetab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The Brain picker sets the toolbar run mode on success (gated byisCliAvailable(), like#runModeMenu), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports nogit(injected viawindow.__codemanCliAvailable).
Tests: test/git-clone.test.ts (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), test/routes/case-clone-routes.test.ts (deliberately unmocked fs, real clone through the endpoint).
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.
Cross-session search
Cross-session search (COD-113/#133): GET /api/search?q=&types=&limit= federates an in-memory search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private externalPath is never read). Pure core searchSources() in search-service.ts (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); harvestSources() in search-routes.ts gathers the in-memory sources. SearchQuerySchema bounds q (1–200), allowlists types (session,event,file), clamps limit (1–60). Returns the {success,data} envelope. Frontend: history-panel search box in terminal-ui.js. Types: src/types/search.ts.
Past sessions in the corpus (#261): the live session map alone made every CLOSED session unfindable, searching a folder name that was sitting in the home screen's Resume list below the box returned nothing. Past sessions now come from src/web/session-history-index.ts: a capped (HISTORY_INDEX_MAX_ITEMS 400) snapshot of the unified list, read synchronously by harvestSources(). ⚠️ It is filled OUTSIDE the request path, which is what preserves the no-fs property above: /api/sessions/unified publishes it as a side effect (free, it just merged that list, and the home screen fetches it whenever it opens, which is the same screen the search box lives on), and ensureHistorySessionIndexFresh(), fire-and-forget, single-flight, TTL-guarded (60s), kicks a rebuild when a search finds it stale. A cold process therefore answers its first search without history and its second with it; never await the refresher from a handler. ⚠️ The snapshot is stored UNSCOPED with a per-row owner (undefined = host-wide transcript history), and harvestSources() re-applies canAccessOwned() per row, the same rule /api/sessions/unified applies when it drops history for non-admins. A scoped (non-admin) unified request therefore re-merges unscoped before publishing, rather than writing its own subset into the shared snapshot. ⚠️ Live rows are harvested FIRST and win the dedupe, so a session that is both live and in the snapshot keeps jumpTo.kind:'session'; history rows get 'resume-session' (with claudeSessionId/workingDir), because selecting a tab that no longer exists is a silent no-op the user reads as a broken result. Tests: test/session-history-index.test.ts, test/routes/search-routes.test.ts.
Away digest
Away digest (COD-41/#136): GET /api/away-digest?range=&since=&until=&lastViewed= aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in web/away-digest.ts (resolveAwayDigestRange() validates the window — since-last-visit/1h/today/24h/custom, server-local TZ; buildAwayDigest() classifies). Header-button modal in panels-ui.js (button hidden on phones — regression-guarded). ⚠️ Returns {success:true,digest} (a legacy raw-ish shape, consistent with the other raw GET handlers in system-routes.ts — {entries}/{config}/{files}/getSystemStats()); frontend + tests read .digest. Subagent lookback is a fixed 60-min window regardless of range.
Detached start and service install
codeman web -d / codeman service install (issue #231). Two answers to "keep it running", split by how long: -d survives the shell, the service survives a reboot. src/daemon-control.ts and src/service-installer.ts, both splitting pure builders (argv, URLs, pidfile parsing, unit-file text) from the IO.
Why a flag at all, when nohup codeman web & looks like it should work: it does not reliably. Node re-arms SIGHUP to its default disposition even when it inherits "ignore" from nohup (verified: nohup node script.js & then kill -HUP prints "Hangup" and dies; /proc/<pid>/status shows SIGHUP absent from SigIgn, where nohup sleep has it set). cli.ts then adds a SIGHUP handler that shuts the server down gracefully, so a delivered HUP always stops it. What actually works is removing the shell's ability to send one: disown in the user's shell, or detached: true (setsid) here. zsh HUPs running jobs on exit by default, bash does not on a clean exit but does when it receives SIGHUP itself, which is why "does & survive?" gets opposite answers on macOS and Linux.
Invariants:
- Never a second server on one data dir.
-dandservice installboth check the pidfile AND probe/api/statusfirst, and refuse. This is the instance-isolation hazard, not politeness: a second instance on the sharedtmux -L codemansocket discovers the first one's live sessions, attaches PTYs to them and resizes them (see Instance isolation). - Never report success that was not observed. The parent polls
/api/statusuntil the child answers or exits, then prints the URL or the tail ofweb.log. Alaunchctl load, asystemctl enable --nowand a plain spawn are all silent about a server that starts and dies half a second later, which is whyinstall.shverifies too. The log is append-only across launches, so each start writes a separator line and the failure tail begins there. - A 401 counts as up.
CODEMAN_PASSWORDgates/api/status, so requiring a 200 would make readiness detection fail on exactly the installs that took security advice. The body is still checked for"success"so an unrelated service squatting on the port is not mistaken for Codeman. --stopverifies identity before signalling. Pids are recycled; a stale pidfile plus a blindprocess.killis how a tool SIGTERMs someone's database.ps -o command=(portable to macOS) must still look like a Codeman web process. Whenpsitself fails, the pid is treated as ours rather than orphaning the pidfile.- One source of truth for the job name.
src/config/service-names.tsholds the systemd unit name and launchd label used byinstall.sh,detectSupervisor()in self-update, andservice install. Drift here is silent and bad:service installwould supervise a SECOND copy alongside the installer's. The names are instance-scoped (CODEMAN_INSTANCE=beta→codeman-web-beta.service/com.codeman.beta.web) so a beta cannot overwrite the production unit, and are byte-identical to the historical names for the default instance. - PATH is the reason hand-written units fail. launchd hands a job
/usr/bin:/bin:/usr/sbin:/sbinand systemd's user manager is nearly as bare, so a Homebrew or nvmnode,tmuxorclaudeis simply absent. The unit therefore carries the installing shell's PATH with the running node's directory in front;node_modules/.binentries are dropped, since npx injects those for one command and they would outlive the checkout. - No secrets in unit files.
CODEMAN_PASSWORDpresent in the installing shell is NOT copied into the plist/unit; the operator is told to add it.CODEMAN_INSTANCEIS copied, because without it a supervised beta would silently run against the production data dir and tmux socket.
Self-update
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.
Why there is a reverse proxy at all. A direct <iframe src="http://box:4000"> fails three independent ways in the shipped deployment: (1) prod serves --https behind tailscale serve, and browsers hard-block http:// iframes on an HTTPS page with no override (none at all on iOS Safari); (2) Grafana/Portainer/Home-Assistant-class dashboards send X-Frame-Options: DENY or frame-ancestors 'none'; (3) our own default-src 'self' CSP makes frame-src fall back to 'self'. Serving the dashboard through Codeman's origin dissolves all three, and as a bonus leaves the production CSP byte-for-byte unchanged, because /webview/... is already covered by 'self'. A direct mode still exists for HTTPS targets that permit framing; POST /api/webviews/probe runs a server-side reachability + framing check and recommends which to use.
Origin-scoped, not path-scoped. /webview/<cap>/x/y always maps to <upstream origin>/x/y, never <upstream origin><saved path>/x/y. Dashboards reference assets root-absolutely (/public/build/app.js), so origin-scoping is the only mapping under which those resolve; the saved URL's own path+query is used solely as what /webview/<cap>/ itself serves.
The capability, and why the auth exemption is safe. A sandboxed iframe (no allow-same-origin) is OPAQUE-ORIGIN, so every request it makes is cross-site: the SameSite=lax codeman_session cookie is never attached, and writes and WS upgrades arrive with Origin: null, which isAllowedRequestOrigin rejects by design. Cookie auth therefore cannot work. src/webview-capabilities.ts mints a 192-bit randomBytes token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which middleware/auth.ts recognizes via hasValidWebviewCapability() to skip the cookie and Origin checks. ⚠️ The Host allowlist is never bypassed, so DNS-rebinding protection is intact. ⚠️ There is a second, Referer-keyed form of the exemption for root-absolute assets that <base href> cannot rewrite (fetch('/api/data'), url(/img.png) in a stylesheet); it is the only exemption decided by a request-supplied header, so it is fenced to safe methods on paths that resolve to NO registered Codeman route (matchesRegisteredRoute), plus a blanket refusal of /ws/ and /q/. Without that fence a page could present a webview Referer and skip auth on a real API route. ⚠️ The fence uses findRoute(), NOT hasRoute(): hasRoute matches the registered PATTERN literally, so /api/sessions/abc reports false against /api/sessions/:id and would hand out an exemption on a live route. It also has to treat @fastify/static's root catch-all (mounted at /, matches everything) as "no real route", which is detectable because a root catch-all is the only route whose * param equals the whole request path. /api used to be refused by prefix instead, which permanently broke dashboards serving their own assets from an /api/... namespace. test/webview-auth-exemption.test.ts pins every edge.
Egress guard (2026-09-04). The proxy's reach is a documented property, but 169.254.169.254 sat inside it: the URL schema accepted any http(s) host, the proxy relayed arbitrary request headers and PUT/POST, and a curl PoC pulled an IMDSv2-shaped request through to a loopback echo server with no cookie. src/web/webview-egress-policy.ts (pure) refuses link-local and the fixed cloud-metadata addresses, and ONLY those: loopback and RFC1918 stay allowed because a localhost Grafana is the feature (test/webview-proxy.test.ts pins 127.0.0.1:4000 as valid). ⚠️ It is applied at three stages and each is load-bearing: the Zod schema (a clear refusal at save time), a synchronous hostname check on every connect (Node's net.connect skips DNS for an IP literal, so a lookup hook never sees one), and a lookup hook (src/web/webview-egress.ts) on the undici Agent behind webviewFetch() and on the ws client, which judges the RESOLVED addresses of a name and refuses when ANY of them is blocked (autoSelectFamily races the whole list). The hook is what closes rebinding: a hostname-string check alone, like the push-endpoint guard's, is bypassed by an attacker's own DNS. ⚠️ The proxy uses the undici PACKAGE's own fetch with that package's own Agent, never Node's global fetch with a foreign dispatcher: Node bundles its own undici, and a dispatch-protocol mismatch between package and bundle fails in ways no unit test here would see. Tests: test/webview-egress-policy.test.ts, test/webview-egress.test.ts (a real Agent against a real local server with an injected resolver), the egress block in test/routes/webview-routes.test.ts (schema refusal, probe refusal, and a record written straight to the store to prove the proxy re-judges).
Capabilities die with the login. revokeOwner() shipped for two releases with a docstring claiming logout called it and NO caller: the rolling TTL is refreshed on every use, so a leaked proxy URL stayed valid for as long as anything polled it. It is now called from POST /api/logout (own identity; undefined in single-user mode, i.e. everything), the admin forced-logout route and user deletion, pinned by test/webview-capability-revocation.test.ts. Proxied responses additionally carry Referrer-Policy: same-origin (the upstream's own policy is dropped): every URL inside the frame carries the capability, and a dashboard on no-referrer-when-downgrade or unsafe-url handed it to any third-party host it linked. same-origin keeps the Referer the 404 fallback and refererPath depend on, since both compare URL origins; a <meta name="referrer"> inside the document can still override it, which is the dashboard author's call about their own page.
Sandbox default. The iframe carries allow-scripts allow-forms allow-popups allow-downloads allow-modals and gains allow-same-origin ONLY when the dashboard is explicitly trusted. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In both modes, Authorization and the codeman_session cookie are stripped before the upstream request (buildUpstreamRequestHeaders), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand CODEMAN_PASSWORD to the dashboard.
Two things a sandboxed frame breaks that are invisible to curl. Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
- Root-absolute URLs built at runtime.
<base href>only governs URLs the HTML parser resolves;fetch('/api/data')bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. TheReferer-keyed 404 fallback is only a rescue (it fires after every real route missed, and only when the browser sends a usableReferer), so the fix isruntimeUrlShim(): a small script injected right after<base>that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN<base>(an early return there silently breaks exactly the pages that need it most). ⚠️ The DOM sinks are as load-bearing asfetch. Patching onlyfetch/XHR/WebSocket/EventSourceleavescontainer.innerHTML = '<img src="/api/hero?slug=x">'andimg.src = '/api/slide'untouched, and neither of the other layers can reach those either (<base>never applies to root-absolute URLs, andrewriteHtml()only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's data loads while every image stays broken. So the shim also wrapsinnerHTML/outerHTML/insertAdjacentHTML,setAttribute/setAttributeNS, and thesrc/srcset/href/poster/data/actionproperty setters, with aMutationObserveras a last net for sinks not patched above. Every rewrite routes through the same idempotentrw(), which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its ownouterHTMLwould otherwise double-prefix). The DOM half is pinned in jsdom bytest/webview-proxy.test.ts;curlcannot see any of it. - CORS on same-host requests. An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its
fetch/XHR are CORS-checked and its preflights carryOrigin: null. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaquenet::ERR_FAILED.buildProxyCorsHeaders()echoes the origin (omittingallow-credentialsfornull, which browsers reject in combination), upstreamaccess-control-*headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️registerSecurityHeadersanswers EVERYOPTIONSwith a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit must exempt a valid webview capability or every preflight fails.curlcannot reproduce any of this because curl does not enforce CORS.
Rewrites, each load-bearing (pure + unit-tested in src/web/webview-proxy.ts): drop x-frame-options and the CSP frame-ancestors directive (the point of the proxy); drop content-encoding/content-length because undici's fetch already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite Location for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase Set-Cookie Path onto the prefix and drop Domain; inject <base href> and rebase root-absolute src/href/action. resolveUpstreamUrl() returns null on anything escaping the upstream origin.
Fastify specifics. The proxy lives in an encapsulated plugin scope with removeAllContentTypeParsers() + a '*' pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps text/plain RAW, which was a real CSRF hole once). One app.route({ method:'GET', handler, wsHandler }) serves both HTTP and the WebSocket upgrade; HEAD must NOT be declared on the sibling route because exposeHeadRoutes already derives it and the duplicate is a startup error. ⚠️ Every exit path in the proxy handler RETURNS reply.send(...): the handler is async, and a bare return after reply.send(stream) resolves the handler promise to undefined before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off setNotFoundHandler, so real Codeman routes always win.
Frontend (src/web/public/webview-tabs.js, load order 12.5): web tabs render into #sessionTabs carrying data-webview-id instead of data-id, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ _renderSessionTabsImmediate()'s canIncremental check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (0 === 0), where opening a dashboard would never draw its tab. ⚠️ isActive for a session tab is id === activeSessionId && !activeWebviewId: activeSessionId stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past maxLiveFrames) so tab switching never reloads a dashboard.
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) 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 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 -us 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. ⚠️ 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 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 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.
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:
- The branch lives in
attachCustomKeyEventHandler(terminal-ui.js) and the no-selection path returnstruewith nopreventDefault(), so xterm still evaluatesCtrl+Cinto0x03. Returningfalsealone does not cancel the event either way: xterm's_keyDowncalls the custom handler before its owncancel(), which is exactly why the copy path callspreventDefault()explicitly (otherwise the browser also runs its native copy on top). copyTerminalSelectionis a registryactiondeliberately missing fromSHORTCUT_ACTIONS(same trick ascommand-palette): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, whichpreventDefault()s every match it dispatches, skips it and lets the terminal handler decide.- The gate is keydown-only (the custom handler also runs for
keypress/keyup), andCtrl+Shift+Cnever falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside.
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, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest:
- 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 acat'd block indented four comes out at two. The ceiling rule below keeps its relative indentation intact andcopyStripMargin(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 aswindow.__codemanTranscriptGutter, built by filteringenabledClis()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 agit logbody 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 perCtrl+Cto 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 tomargincolumns more off whatever the first left. The trailing trim alone is a fixed point, andcopyTerminalSelection()relied on that by re-cleaning whatever argument it was handed, which dedented every claude and codex copy twice on theCtrl+Cpath — 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 insideinitTerminal'sattachCustomKeyEventHandlerclosure over a real xterm that the vm harness cannot build. - A COLUMN selection is returned untouched. Alt+drag makes one (xterm's
shouldColumnSelectkeys onaltKeyalone, and neitherTerminalCodeman 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 readsterminal._core._selectionService._activeSelectionMode(SelectionMode.COLUMNis 3) and cleans normally if a future xterm renames it. - 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.tsreads_selectionService.selectionStart, whose getter returnsSelectionModel.finalSelectionStart, and that swaps the pair whenareSelectionValuesReversed()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 > 0then 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. - 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: theCtrl+Cgate 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 as0x03anyway. 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.
Auto Copy (copy-on-select)
Auto Copy (autoCopySelection, per-device, default OFF) puts a finished terminal selection on the clipboard without a keystroke. It is a thin layer over the smart-copy machinery above and shares _copyText() with it, but the two paths differ in every decision that matters:
- It fires at the END of a gesture, never on selection change.
onSelectionChangeruns for every cell a drag crosses, so copying there would be one clipboard write per mouse move. The callback only ARMS_autoCopyPending; the flush is a document-levelmouseuplistener installed once ininitTerminal, plus explicit calls from the touch selection path. - The flush is synchronous inside the handler. Both clipboard paths need user activation: Firefox gates
navigator.clipboard.writeTexton it, anddocument.execCommand('copy')(the plain-HTTP fallback thatinstall.sh's LAN option lands on) has to run in the gesture's own task. Deferring to a timer or waiting foronSelectionChangeloses it, and the failure is browser-specific and invisible in Chrome. - The listener is on
document, not the terminal container, because a drag that ends outside the terminal (sweeping up past the header) delivers its mouseup to the document. Unrelated mouseups elsewhere on the page are filtered by the decision helper, not by the listener's target. - Touch has its own entry point.
_endTouchSelectionGesture()and_selectTouchSelectionLine()call the flush directly, because the touch pathpreventDefault()s its touchend (that is what stops the compat mouse pair from stealing the selection back), so no mouseup ever reaches the document there. Without those two calls the toggle is simply dead on a phone. - It must NOT do what
copyTerminalSelection()does. That one clears the selection (so a secondCtrl+Cis an interrupt) and focuses the terminal. Clearing would make text vanish from under the cursor that just highlighted it, and focusing opens the on-screen keyboard over it on a phone. Focus is instead RESTORED to whatever held it before the copy, which only matters for theexecCommandfallback (it focuses a temp textarea on the way through); the Clipboard API path never moves focus at all.
⚠️ The toggle is read before the selection is. _flushAutoCopySelection() resolves _autoCopySelectionEnabled() first and only then reads and cleans, because Auto Copy is OFF by default and a selection can run to the 50 000-row scrollback ceiling; cleaning ahead of the check would spend that work on every mouseup on the page. The clean runs before decideAutoCopy() so its dedupe and its cap both measure the text that actually reaches the clipboard.
decideAutoCopy() (constants.js, pure) holds the guards: setting off, blank or whitespace-only text (what a drag across empty cells produces), and a AUTO_COPY_MAX_CHARS (1M) cap. ⚠️ The cap is not decoration: a drag off the top of the viewport autoscrolls, so one gesture can sweep the whole 50k-line scrollback. Past it the copy is REFUSED rather than truncated, with a toast pointing at Ctrl+C, which still copies everything through the explicit path.
⚠️ Two dedupe rules, and both earn their place. A genuine selection change (pending) always copies, so re-selecting the same text after copying something else in between still works. Otherwise only text differing from the last auto-copy does, which is what stops an unrelated mouseup from re-copying a stale selection AND what makes the first copy of a drag work at all: xterm fires onSelectionChange from its own document mouseup handler, and listener order between the two is registration order, not something this code controls. Gating on pending alone silently drops that first copy.
Feedback is silent on success except ONCE per page load (a feature that works by doing nothing visible cannot otherwise be told from a dead toggle); failures and refusals toast, throttled to 10s so a permanently blocked clipboard cannot paint a toast on every drag. The setting is per-device on both counts required by the settings rule: it is in displayKeys AND absent from the .strict() SettingsUpdateSchema (clipboard access differs by device and by origin, and the plain-HTTP LAN install has no navigator.clipboard at all). Tests: test/terminal-auto-copy.test.ts.
Terminal paste (Ctrl+V)
The Ctrl+V paste trap consumes exactly ONE paste event. _handleImagePaste() (image-input.js) appends a hidden contenteditable div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through terminal.paste(), so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both:
document.execCommand('paste'), which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trustedpasteevent carrying the real clipboard and still returnsfalse, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing.- The keydown's own default action. The
Ctrl+Vbranch inattachCustomKeyEventHandlerreturnsfalse, which stops xterm from evaluating the key into^Vbut does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out.trap.focus()has already run by then, so the browser sends its own paste to the trap as well.
Measured against a live install, one Ctrl+V each: Firefox delivers two paste events, Chromium and WebKit one. Handling both wrote the clipboard text to the PTY twice, which is why Ctrl+V pasted twice while right-click → Paste pasted once. That menu path involves no keydown, so route 2 cannot exist for it.
⚠️ Route 2 alone is enough in all three engines, so route 1 is redundant where it can be measured. Strip the execCommand('paste') call out and each of the three still delivers exactly one paste event to the trap, Firefox included. The call is kept anyway, because the trap technique was written for the mobile engines that a desktop measurement cannot reach, and a browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea instead. Text paste survives that on xterm's own handlePasteEvent; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup.
The guard is a one-shot flag on each trap rather than a browser test or a reading of execCommand's return value, so any count of events produces one insert. Tests: test/image-paste-trap.test.ts.
Settings surface: App Settings, Session Options, Add Case
One visual language, three modals. #appSettingsModal, #sessionOptionsModal and #createCaseModal share the set-* surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) scope in styles.css. An :is() list takes the specificity of its most specific argument, and all three arguments are ids, so every rule kept exactly the weight it had when the block was #appSettingsModal-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
Anatomy: .set-shell → .set-shell-head (title + .set-head-actions) + .set-body (grid of .set-rail and .set-doc). The rail holds .set-search, .set-rail-items and .set-rail-foot; the document holds .set-sections, each with .set-section-head + .set-section-blurb + .set-groups of .set-rows (.set-row-text = .set-row-label + .set-row-desc; controls sit in .set-row-actions). ⚠️ Close is deliberately first in the DOM inside .set-head-actions so the focus trap lands on it rather than on Save; row-reverse is what puts Save to its left visually on phones.
The rail means two different things, and that is deliberate:
- App Settings is a table of contents over ONE scrolling document. Every section stays mounted; rail entries carry
data-sectionandswitchSettingsTab(id)keeps its historical name but SCROLLS instead of hiding. Section order in the DOM issettings-updates|terminal|layout|appearance|models|clis|notifications|voice|shortcuts|system, and the modal opens scrolled to Updates, which carries ONLY the current version and the update action: what this install runs, and whether a newer release is waiting, before any preference. The rest of the old System section (Paths, Automation, Remote access) tails the document under System, set once and rarely touched, which puts Terminal & Input (where Local Echo lives) second in reach. - Session Options and Add Case really SWITCH:
switchOptionsTab(tab)/switchCaseModalTab(tab)(session-ui.js) show one.set-sectionand put.hiddenon the rest, then reset the document scroll so a switched-to section starts at its own top. Stacking them into one document would bury both ends: Summary owns its own scroller, Respawn is long, and Add Case is six independent forms. Their rail entries call the handler from an inlineonclickand pair by id (data-tab="respawn"↔#respawn-tab;data-tab="case-clone"↔#case-clone).
⚠️ The Session Options rail reads "Session" but the key is still context. The tab was renamed because "context" described only one of its three groups (they are now Identity / Context window / Behavior), and the rename is label-only: data-tab="context", id="context-tab" and switchOptionsTab('context') are unchanged, including the call in openSessionOptions that lands external-CLI sessions somewhere valid.
⚠️ Two sizes on one surface, on purpose. App Settings is a long dense document you scan, and stays tight: min(760px, 95vw) × min(620px, 86vh), 176px rail, 15px doc padding, 5px 10px rows, 0.76rem row labels. Session Options and Add Case are the opposite (a handful of short panels you act on once), and at that density they read as a few small fields marooned in a large empty frame with rail entries too small to aim at. Both override to min(900px, 96vw) wide, a 236px rail with 0.9rem entries and 19px icons, 0.88rem row labels, and height: auto between min(560px, 80vh) and 88vh, so the shell is as tall as the panel showing instead of a fixed box the content rattles in (Summary used to open two-thirds empty). The per-modal override blocks are the design, not drift; do not "unify" them back into the shared scope.
Phones (≤860px) keep the same document and change only the navigation. App Settings collapses the rail to its search field and hands over to the sticky #appSettingsJump pill. Session Options and Add Case have neither a pill nor a search, so their .set-rail-items becomes a horizontal, scrollable strip instead, close to the tab bar it replaced, so the phone gesture is unchanged. The active marker switches from the vertical rail's left bar (which reads as a stray tick when laid out horizontally) to a filled pill.
Add Case keeps its legacy .form-row markup, in all six panels, because every id in them is read back by session-ui.js (resetCaseModalFields, submitCaseModal, linkRemoteCase, linkDockerCase); restructuring the forms to reach the row classes would be a lot of risk for no visual gain. Instead an adapter block scoped to #createCaseModal .set-doc maps the old primitives onto the look: a .form-row paints as a row card, its > label as a row label, its .form-hint as a row description, and <details class="advanced-options"> as a collapsed group head. .form-row everywhere else in the app is untouched.
⚠️ summary { display: flex } drops the browser's own disclosure triangle. With the adapter's flex summary and no replacement, all five collapsed blocks (Clone options, Container settings, Advanced SSH, Discover existing sessions, Advanced container settings) rendered as plain uppercase headings with nothing to say they open, and were reported as exactly that. The chevron is now explicit markup (.set-adv-chev, rotated 180° on [open], matching the Advanced group in App Settings), and the native marker is suppressed in both spellings (list-style: none and ::-webkit-details-marker { display: none }) so a browser that would still paint one does not end up with two.
Respawn is ordered around what people open it for. Auto-resume on usage limit is a .set-callout (accent card, icon, whole card as the hit target) rather than the first row of a list, because it is what turns a limit-halted overnight run back on; the armed "resumes at HH:MM" note renders inside it. ⚠️ That callout <label> wraps its own switch and carries no for=: nesting already associates them, and the label+for pair has historically double-fired. Loop control (status + Enable/Stop) sits above the loop configuration: a running loop is what you open the tab to see or stop, and Enable is the point of the tab either way. The three respawn cycle steps are real checkboxes in a .set-checks / .set-check row card, not chips (they were chips briefly): they are numbered steps of one sequence, and chips read as a set of independent tags.
⚠️ .modal-tabs / .modal-tab-btn / .modal-tab-content are RETIRED. No modal uses them and their CSS is deleted from both stylesheets. The guard in test/app-settings-structure.test.ts flipped from "the settings modal must not steal these shared classes" to "nothing uses them any more", so a reappearance now means a modal drifted back off the shared surface.
Header & Panels live preview: a scale model of the app (header bar, right-docked panels, toolbar, floating windows) rebuilt by _syncLayoutPreview() on every chip change, so "what does this add" is answered in place before saving. ⚠️ It owns no icons of its own: it CLONES .set-chip-ico out of the chip, so each icon has exactly one copy in index.html and a chip can never drift from the button it previews. A chip joins the preview by carrying data-preview (which slot) + data-preview-order (where in it); readouts that are not buttons (plan usage, CPU, font size) use data-preview-text instead. The frame is painted from skin tokens only (hardcoded black alphas turned it into a grey slab on the four light skins) and is marked data-i18n-skip: the mock tab names are decoration and the labels inside are copies of chip text i18n has already translated. Every Header & Panels chip also carries the icon of the button it switches on, so the list reads as the header itself rather than as a column of names.
⚠️ The load/save contract is getElementById by id. openAppSettings()/saveAppSettings() (and openSessionOptions()) read every control by a fixed id, so moving a control between sections or groups is free, but renaming or dropping one silently stops it loading or saving. Two static guards: test/app-settings-structure.test.ts (rail and document agree on order, System leads with the version above the paths, every previewed chip has both an icon to clone and a slot that exists, the retired classes stay gone) and test/session-options-structure.test.ts (rail↔section pairing, the ids openSessionOptions reads, the one-visible-section invariant, the Claude-only entries).
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.
Header button visibility (multi-monitor, response viewer, file viewer, cron)
Multi-monitor button (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). app.launchMultiMonitor() (in panels-ui.js) POSTs /api/system/span-displays, which spawns scripts/span-codeman.sh — a fresh, maximized browser --app window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. Opt-in: hidden by default; enable under App Settings → Header & Panels → Header buttons ("Multi-monitor Button", showMultiMonitorButton). The button carries a btn-multimonitor--hidden class in the template; renderIndexHtml strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and applyHeaderVisibilitySettings() toggles the same class live on save. Solo (detached) windows hide it via body.solo-mode.
Response-viewer (eye) button (header) is likewise hidden by default — enable under App Settings → Header & Panels → Header buttons → Response Viewer (showResponseViewer). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under CODEX_HOME (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in test/routes/session-routes-codex-last-response.test.ts.
⚠️ A Claude pane's conversation is identified by the pane's own Enter, never by "newest entry for this cwd". ~/.claude/history.jsonl records every submitted prompt as {project, sessionId, timestamp}, and /clear moves the pane to a fresh <uuid>.jsonl that nothing on the PTY announces — so the viewer has to re-derive the live conversation. Keying that off project alone was the bug: a cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain claude the user runs in their own terminal, so the eye followed whichever of those conversations was typed into last and showed a stranger's transcript. resolveActiveClaudeSessionIdFromHistory() instead credits an entry to a pane only when it lands within CLAUDE_SUBMIT_MATCH_MS of that pane's Session.lastSubmitAt and no other pane on the same cwd submitted closer — the same last-submit correlation the Codex locator uses. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation.
⚠️ A first-hand conversation id outranks every correlation, and the correlation must never run when one exists. UserPromptSubmit and Stop hook payloads carry session_id — the pane's LIVE conversation, reported from inside the CLI process — and reach Codeman addressed by that pane's own $CODEMAN_SESSION_ID. That binding is a fact: it never consults workingDir, so it cannot be claimed by a sibling pane on the same folder, by a closed tab, or by a bare claude in the user's terminal. Session.claudeSessionIdIsFirstHand gates resolveActiveClaudeSessionIdFromHistory() at its first line, so the number of prompts eligible for cwd-based guessing goes DOWN, never up. There is no TTL: if hooks stop arriving the last hook-supplied id is kept forever rather than falling back to guessing, which is the same rule as the paragraph below. ⚠️ This is also the only fix for a pane driven by attaching to its tmux session directly. lastSubmitAt was bumped only by Session.write()/writeViaMux(), i.e. input that flows through Codeman, so such a pane's anchor stayed 0 and the resolver returned at if (!submitAt) for the pane's entire life. The hook stamps it too (markPromptSubmitted()), so it finally means "a prompt was submitted". ⚠️ Only a first-hand adoption may extend Session.claudeSessionChain — a correlated guess writing into the pane's permanent record is precisely the bug the paragraph above describes, made durable. The chain is persisted because /clear is otherwise unrecoverable: once the pane moves on, the predecessor id exists nowhere else. Its tail re-pins the conversation on a RESTORED mux attach, where the launch id is a lie (the CLI never stopped and may have /cleared before the restart); a NEW pane has an empty chain and keeps the launch id unchanged. ⚠️ The hook's stdout must stay discarded, using curl's own -o /dev/null (curlCmdSilent): Claude Code injects a UserPromptSubmit hook's stdout into the model's context — the CLI's own hook reference says "Exit code 0 - stdout shown to Claude" — so an undiscarded curl pastes Codeman's {"success":true,…} envelope into the user's own prompt on every turn. ⚠️ A trailing >/dev/null does NOT work and looks like it does: curlCmd already ends … 2>/dev/null || true, and in pipeline || true >/dev/null the shell binds the redirection to true, which never runs on the success path. An endsWith('>/dev/null') assertion passes on exactly that broken form, so the test asserts the -o flag instead. The other events feed SSE, where their stdout is harmless — hence a separate builder rather than a change to curlCmd. Tests: test/hooks-config.test.ts, test/routes/hook-event-routes.test.ts, test/routes/session-routes-claude-last-response.test.ts.
⚠️ Session.lastSubmitAt is persisted state, not a runtime counter. start() reassigns _claudeSessionId = resumeSessionId || id on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its launch conversation, even when the CLI moved on via /clear hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through SessionState.lastSubmitAt and is restored in restoreMuxSessions(). Drop it from toState() and recovered panes silently show the pre-/clear transcript until the user types again. Restoring a stale anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
⚠️ The Claude viewer emits one message per model message and groups them with turn; it never concatenates them. A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per row was the original bug (#169) — but the fix overshot to one card per human turn, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real ~/.claude/projects (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no message.id carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying {kind, label, role, text, timestamp, turn}; the frontend renders a same-role run inside one turn as badge-less continuation segments (.rv-msg-cont), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ A prompt typed while Claude is working is recorded ONLY as an attachment/queued_command row — the CLI never re-emits it as a user row — so reading only user rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when attachment.origin.kind === 'human' and commandMode === 'prompt'; the CLI's own queue entries (commandMode: 'task-notification') carry no origin key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ data.text (no ?context=full) is frozen on the last assistant row and must never be derived from messages.at(-1) — agent pollers hash it (skills/codeman/preamble.sh), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render text: it asks for ?context=turn, which returns the assistant messages of the last ANSWERED turn (selectLastAnsweredTurn() in src/web/response-viewer-transcript.ts: the highest turn that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-turn continuation gate as the full view. text alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no turn (Codex, the pane parser, an older server) return text only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered restored-<uuid8> tmux placeholder carries a stale cwd, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: test/routes/session-routes-claude-last-response.test.ts, test/response-viewer-turn-segments.test.ts. Purely client-side (no renderIndexHtml step): the template ships with btn-response-viewer-header--hidden and applyHeaderVisibilitySettings() (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is display:inline-flex !important, so an inline style can't override it. showResponseViewer is in the displayKeys per-device set (settings-ui.js), so it does NOT sync across devices.
File Viewer button (header, 1.4.1) is shown by default on desktop since 211f3c0 (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (showFileViewerButton, in the per-device displayKeys set, fallback default true). Purely client-side like the response viewer: the template now ships the button VISIBLE (no --hidden class) and applyHeaderVisibilitySettings() toggles the btn-file-viewer--hidden marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (panels-ui.js). The same commit set the default desktop header to WS/CPU/MEM + File Viewer + gear: the token-count chip (showTokenCount, no settings-UI toggle) and the lifecycle-log button (showLifecycleLog) both default OFF now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The Cron toolbar button joined the same opt-in pattern in 1.6.0: template ships btn-cron--hidden, applyHeaderVisibilitySettings() toggles it via the per-device showCronButton setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
Session list layout (header strip vs. left sidebar)
The session list can render as the horizontal header strip (default) or as a collapsible left sidebar — App Settings → Layout → Tabs → Session List Layout (sessionListLayout: 'header' | 'sidebar', in the per-device displayKeys set, so it never syncs across devices; also in SettingsUpdateSchema, which is .strict() — without that entry the server 400s the ENTIRE settings PUT and every unrelated setting silently stops persisting). ⚠️ There is exactly ONE #sessionTabs element and applySessionListLayout() RE-PARENTS it between #sessionTabsHost (in <header>) and #sessionSidebarList (in the <aside>, a flex sibling of .terminal-wrap so the terminal shrinks and terminal-ui.js's ResizeObserver refits xterm on its own). It must never be cloned or rebuilt: app.$(id) caches elements by id and NEVER invalidates, and settings-ui.js / webview-tabs.js resolve the same id independently, so a rebuilt container leaves every consumer writing into a detached orphan — silently, with no error. Everything else is CSS keyed off html[data-session-list] / html[data-sidebar], both written by a pre-paint script in <head> so the loading skeleton already matches. Consequences: the renderers, drag/keyboard handlers, web tabs (data-webview-id rows stay in the same list, keeping the shared Alt+N numbering and the single-active-tab invariant) and the generated gesture bundle (TAB_SELECTOR/DOCK_SELECTOR match on class names that are unchanged) all need zero edits.
⚠️ 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 true, silently ceding the keystroke to the BROWSER's own handling (e.g. Chrome's Inspect-Element binding) with no feedback and no copy attempt — Pane A always intercepts it. Ctrl+V stays on xterm's own default paste, since Pane B has no image-paste trap to route it to. ⚠️ buildSplitPickerSessions() also excludes any session with pid === null (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of selectSession()'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no terminal events ever arrive, and Session.write() silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's input frames deliberately carry no cid/seq (ws-routes.ts supports that), matching the no-overlay/no-IME "deliberately plainer" list above, since it has no exactly-once delivery layer to key them against. Design: docs/split-pane-sessions-plan.md.
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).
Gesture control: the source package
Gesture-control source lives in-repo at packages/gesture-control/ (workspace package codeman-gesture-control, was the standalone Ark0N/codeman-gesture-control repo). The transport-agnostic core is src/gesture/* (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); src/codeman/entry.ts is the Codeman consumer that maps grab/drag/drop onto real .session-tab/toolbar buttons and is the bundle entry. Edit there, then run npm run build:gesture (scripts/build-gesture-bundle.mjs → esbuild bundles entry.ts, MediaPipe JS included, into src/web/public/gesture/gesture-codeman.js) and commit the regenerated bundle — the committed bundle is what dev/tsx serves (no bundler at runtime), and scripts/build.mjs reruns the same step so prod always reflects current source. The MediaPipe wasm + model are NOT bundled — loaded at runtime from same-origin /gesture/wasm + /gesture/gesture_recognizer.task, fetched by scripts/fetch-gesture-assets.mjs (gitignored, see Gotchas). entry.ts mounts window.__codemanGesture = new GestureBridge() idempotently at module-eval. A standalone vite playground (npm run dev in the package — fake tabs, no Codeman) lets you iterate on gesture feel in isolation. ⚠️ Keep MP_VERSION in fetch-gesture-assets.mjs in sync with @mediapipe/tasks-vision in packages/gesture-control/package.json.
Theme skins
Theme skins (App Settings → Appearance → Theme): the skin setting selects a palette via a data-skin attribute on <html>. Dark values: daylight-blue (default), daylight-green, og (OG Codeman). Light values: paper-gray, solarized-light, catppuccin-latte, rose-pine-dawn. CSS lives under [data-skin="…"] blocks in styles.css. To avoid a flash-of-wrong-theme, an inline pre-paint script in index.html (<head>) reads localStorage['codeman:skin'] and sets data-skin before first paint; settings-ui.js applySkin() applies it live on save (sets html[data-skin] + window.__codemanSkin, syncs the standalone codeman:skin key with the settings blob, updates the <meta name="theme-color"> from the resolved --bg-dark, and calls terminal-ui.js applyTerminalSkin() to re-theme live terminals). skin is a per-device/client-only setting — it's destructured OUT of the server payload (settings-ui.js, alongside localEchoEnabled/cjkInputEnabled/extendedKeyboardBar), so it does NOT sync across devices.
Adding a skin means touching four places, and each one fails silently on its own: (1) the html[data-skin="…"] token block in styles.css; (2) the xterm ANSI palette in CODEMAN_XTERM_THEMES (terminal-ui.js) — a missing entry falls back to daylight-blue, so the terminal simply stays the wrong color; (3) the pre-paint allowlist array in index.html — a skin missing there is coerced to daylight-blue on every reload, which reads as "my theme keeps resetting"; (4) the <select> in App Settings → Appearance → Theme. test/skin-themes.test.ts is the static four-way guard.
Light skins carry three extra obligations. color-scheme: light on the html[data-skin] block, or the UA renders native <select> popups, date pickers and scrollbars as dark widgets on a light page. xterm minimumContrastRatio: 4.5 (set at construction in terminal-ui.js and panels-ui.js for teammate terminals, and re-applied in applyTerminalSkin()), because CLIs emit colors chosen for dark backgrounds; dark skins keep 1 to avoid the per-cell contrast work. And applyTerminalSkin() must call this._localEchoOverlay?.refreshFont() — the zero-lag overlay caches the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
Shared surface tokens, not literals. Elevated/floating surfaces resolve through --floating-bg, --control-bg{,-hover}, --control-border{,-hover}, --banner-bg-a/b, --modal-backdrop and --elevated-shadow, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits --floating-bg silently inherits the :root value, which is how the OG skin's near-black modals drifted slate. styles.css also defines compatibility aliases (--bg-primary/secondary/tertiary, --text-primary/secondary, --border-color, --accent-color, --success, --error, --danger, --font-mono, --shadow-lg) that forward to the canonical tokens; panels written against those names previously resolved to nothing (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
Custom branding and UI language
Custom branding + UI language (App Settings → Appearance → Identity): displayName is schema-validated (trimmed, 1–40 chars), server-synced, and changes user-facing browser branding/window titles only — NEVER rename npm package/CLI/API/storage/CSS/protocol identifiers. language is a per-device en/zh-CN display key, stripped from the server payload. i18n.js keeps English as the canonical source/fallback, observes newly inserted application DOM for dynamic copy, preserves source strings so live EN↔ZH switching is reversible, and skips terminal/response/file/session-name/user-content surfaces. User display names flow through textContent/attribute APIs and the server title's HTML escaper, never innerHTML.
Foldable settings identity
Foldable settings identity: responsive layout remains width-driven through MobileDetection.getDeviceType(), but the localStorage namespace/defaults use MobileDetection.isHandheldDevice() so an Android foldable keeps codeman-app-settings-mobile after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then navigator.userAgentData.mobile; Android WebView is covered by the Mobile UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as showResponseViewer and extendedKeyboardBar. Regression profile: OPPO Find N5 (unfolded) in test/mobile/devices.ts.
Folding devices
A fold is not a keyboard. KeyboardHandler.handleViewportResize() (mobile-handlers.js) used to read any visual-viewport height drop over 150px as the keyboard appearing. A virtual keyboard only ever takes HEIGHT, so a resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is skipped by both detection branches. Closing an iPhone Duo (626→466pt wide, 890→678pt tall) otherwise 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 was sticky because clearing it needed the height back within 100px of a baseline belonging to a display the user was no longer looking at. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected, and init() seeds lastViewportWidth so the first resize of the page is not itself read as a shape change.
With the keyboard up, the new baseline is window.innerHeight, never the shrunk visual height. The page sets no interactive-widget, so the default resizes-visual mode holds on both engines: the keyboard shrinks only the visual viewport and the layout viewport stays the display's full height (updateLayoutForKeyboard() relies on the same fact). The first version baselined to the shrunk height, which made heightDiff 0, so the settle event the OS animation fires at the new width (or any later address-bar drift) satisfied the hide branch and ran onKeyboardHide() with the keyboard still on screen: accessory bar hidden, the toolbar lift dropped, main's padding cleared. It could not recover, because no further 150px drop can re-arm the show branch against a baseline already sitting at the shrunk height. Reproduced against the real handler in the vm harness with both the fold flavour (626x590 → 466x378 → 466x378) and the rotation flavour (393x359 → 852x150 → 852x160); test/viewport-shape-change.test.ts models the two heights separately and pins both.
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. 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). Removing the class returns scrollLeft to 0, so a resolved mismatch cannot leave the pane parked off-screen.
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
| Layer | Details |
|---|---|
| Auth | Optional HTTP Basic via CODEMAN_USERNAME (defaults to admin) / CODEMAN_PASSWORD env vars. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts) |
| Network bind | Defaults to 127.0.0.1 (loopback). A non-loopback bind (--host/CODEMAN_HOST) without CODEMAN_PASSWORD starts but warns loudly (0.9.0; was fail-closed in COD-29/#107). --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 acknowledges the warning. Classifier: network-auth-policy.ts |
| Host guard | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, *.ts.net/*.trycloudflare.com/*.cfargotunnel.com, the active managed tunnel, and CODEMAN_ALLOWED_HOSTS. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix. registerHostGuard in server.ts; policy in network-auth-policy.ts (buildHostPolicy/isAllowedRequestHost/isAllowedRequestOrigin) |
| CSRF / Origin | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). A missing Origin is allowed so curl/CLI and Claude Code hooks keep working. The global body parser keeps text/plain RAW (no auto-JSON-parse, which had enabled simple-request CSRF); /api/crash-diag self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in ws-routes.ts. Added in c669518 (closes 2026-06-09 review CRITICALs) |
| QR Auth | Single-use 6-char tokens (60s TTL) for tunnel login. See docs/qr-auth-plan.md |
| Sessions | 24h cookie (codeman_session), auto-extend, device context audit |
| Rate limit | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| Hook bypass | /api/hook-event (and /api/status-telemetry, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (CODEMAN_PASSWORD set), the loopback bypass requires the per-instance X-Codeman-Hook-Secret header unconditionally — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/tailscale serve/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via $CODEMAN_HOOK_SECRET_FILE (session env, config/hook-secret.ts); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable refuses without CODEMAN_PASSWORD unless exposure is acknowledged — via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 (env, COD-55) or the per-request acknowledgeUnauthTunnel:true action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
| Env vars | CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from CODEMAN_DOCKER_BRIDGE_HOST or auto-detect) |
| Validation | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*/PI_*) |
| Headers | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
Performance and limits
Buffers, uploads, and terminal history
Target: 20 sessions, 50 agent windows at 60fps. Limits in src/config/: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Terminal history (src/config/terminal-history.ts, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env CODEMAN_MAX_TERMINAL_BUFFER/CODEMAN_TRIM_TERMINAL_TO; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable BufferAccumulator trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (DEFAULT_SCROLLBACK in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so createSession() sets the global default in the same command queue immediately before new-session; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes are schema-validated but inert (only tmuxHistoryLimit is wired); buffer-limits.ts re-exports the defaults. Text/message limits are env-overridable too (CODEMAN_MAX_TEXT_OUTPUT/CODEMAN_TRIM_TEXT_TO/CODEMAN_MAX_MESSAGES). Image upload (image-input.js / config/buffer-limits.ts): up to _maxBatchImages 20 images/batch (bounded concurrency 3), per-file MAX_PASTE_IMAGE_BYTES 50MB (env CODEMAN_MAX_PASTE_IMAGE_BYTES); the mobile camera-roll picker auto-downscales to fit before upload. 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
xterm-zerolag-input is single-source — edit the package, then rebuild the bundles — BOTH echo addons live ONLY in packages/xterm-zerolag-input/src/ (also published to npm as a standalone library — see README "Published Packages") and are bundled into TWO gitignored vendor files with DIFFERENT entry points: vendor/xterm-zerolag-input.js (buffer overlay, entry zerolag-input-addon.ts, window.LocalEchoOverlay alias) and vendor/xterm-predictive-echo.js (codex write-through, entry predictive-echo-addon.ts, window.PredictiveEchoOverlay alias) — dev by scripts/postinstall.js (reruns on npm install), prod by scripts/build.mjs. The separate entries are DELIBERATE: adding exports to the package's index.ts cannot change the zerolag bundle's bytes, which is what keeps predictive-echo changes structurally unable to regress buffer mode (verified sha256-identical at introduction). app.js/terminal-ui.js only consume the addons via new LocalEchoOverlay(terminal) / new PredictiveEchoOverlay(terminal) — there is NO inline copy to keep in sync. Never hand-edit app.js for overlay behavior or commit the gitignored vendor bundles. A public-API break in the package still warrants a separate xterm-zerolag-input version bump in the changeset. Always test on mobile after touching it. See docs/local-echo-overlay-plan.md (buffer mode) and docs/predictive-echo-plan.md (predict mode).
node-pty's macOS spawn-helper must be executable
node-pty ships its macOS spawn-helper non-executable, which breaks every session start on macOS (issues #6 and #204, fixed properly in 1.9.8). node-pty@1.1.0 publishes prebuilds/darwin-<arch>/spawn-helper with mode 0644. On macOS node-pty launches every PTY through that helper (argv[0] = helper_path → pty_posix_spawn() in src/unix/pty.cc, guarded by #if defined(__APPLE__)), so a helper without +x makes posix_spawnp fail EACCES and node-pty throws Error: posix_spawnp failed., surfaced by Codeman as "Failed to start Claude: …". It is macOS-exclusive twice over: spawn-helper is an OS=="mac" gyp target, and node-pty ships prebuilds for darwin + win32 only, so Linux always compiles from source (node-gyp emits an executable helper) and can never reproduce it. That asymmetry is why it kept coming back: a Linux dev box shows nothing wrong.
⚠️ Look in prebuilds/<platform>-<arch>/, not just build/Release/. node-pty's loader (lib/utils.js) tries build/Release, build/Debug, then prebuilds/<platform>-<arch>, and unixTerminal.js derives helperPath from whichever directory the native module loaded out of. The 0.15 fix only chmodded build/Release/spawn-helper, which on macOS does not exist at all (the prebuild is used, so node-gyp never runs), and it resolved the path off require.resolve('node-pty') (= <pkg>/lib/index.js), producing <pkg>/lib/build/Release/spawn-helper, so it was a no-op on every platform.
The repair is a chmod, not a rebuild: the prebuilt binary is fine. scripts/fix-node-pty.mjs (also npm run fix:node-pty) chmods every helper it finds, then proves the result by actually opening a PTY, because a require() alone passes on a broken install (the helper is only touched at spawn time). Only if that probe still fails does it rebuild from source, and it backs prebuilds/ up first: node-pty's install script rmSyncs the whole prebuilds tree the moment npm_config_build_from_source is set and only then shells out to node-gyp, so on a Mac without Xcode command line tools an unconditional rebuild leaves the install with neither a prebuilt nor a compiled binary. ⚠️ Do not reinstate a blind rebuild in postinstall for that reason (it also cost 30-120s on every install).
src/utils/node-pty-repair.ts is the runtime safety net for installs that are already broken: all four pty.spawn() calls in session.ts go through spawnPtyWithHelperRepair(), which chmods and retries once on a posix_spawnp/spawn-helper error and rethrows anything else untouched, so the user never sees a dead session. A second failure rethrows with the npm run fix:node-pty hint attached instead of a bare "posix_spawnp failed". The repair is attempted at most once per process (no chmod storms).
Tooling traps
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.