# 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; for the frontend (`src/web/public/`) that short form lives in `src/web/CLAUDE.md`, which the root file points to. 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-` + `-L codeman-`), 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. ### The prefix table on Codeman's socket holds only send-prefix **Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to ` send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. The same reduction is applied when Codeman creates the nested tmux behind a Docker case (`-L codeman-docker`) or an owned remote session (`-L codeman-remote`). Those servers set `prefix C-q` and used to keep tmux's stock prefix table, so inside the pane Ctrl+Q then `)` `(` `s` `w` or `L` ran `switch-client` / `choose-tree` on the inner server and moved its client onto a sibling session. `nestedPrefixTableResetCommands()` (bind `C-q send-prefix`, then `unbind-key -a -T prefix`, then bind it again, so a user config that left the prefix table empty cannot make `unbind-key -a` abort the `\;` chain) is appended to both create chains, before `remain-on-exit failed` on the remote chain so a remote tmux that rejects `failed` cannot drop it. Those sockets are dedicated to Codeman, so the server-global table is the right scope. A non-owned remote attach joins another Codeman's `-L codeman` and does not run it. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C- detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide. F1 and the held-Ctrl `C-d` still detach every client for the length of an attach (a way out, not a session move). **`M-1`..`M-9` do not move other clients.** `bindSwitchKey` binds them to `if-shell -F` (`tuiSwitchGuard`) so `switch-client` runs only when `#{client_tty}` is the TUI's terminal and `#{client_pid}` equals `@codeman-tui-pid`. A one-shot `client-attached[90]` hook (`tuiSwitchAttachHook`) sets that option as the TUI's client attaches, then removes itself. It is armed as the handoff's last tmux call before `spawnSync`, after the keys and the per-session bars, so only a TUI killed during the attach's own startup can leave that hook armed for its tty. A browser tab does not match, including Esc followed by a digit within `escape-time` (500 ms on tmux 3.4), which arrives as the same `M-`. A client that fails the guard gets the key back: the `if-shell` else argument is `send-keys M-`, which writes Esc and the digit into that client's own pane, so a vim `2j` in a browser tab is not eaten by the root binding. If the TUI is killed before `restore()`, the binding can remain, but the stored pid is the dead client and the hook will not re-arm a pty that reuses the number, so the key never switches and every client gets it in its pane through the else branch. `restore()` removes the keys only while they still name this tty, and the next attach that claims the strip releases any of these bindings first. A second TUI attach replaces the keys (one binding per key). Escape-time on Codeman's socket stays at tmux's default: lowering it would change Esc delivery for every pane, and the guard already refuses other clients. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client, including the nested `C-q` table), `test/tui/tui-switch-guard.test.ts` (the guarded Alt keys against a second client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`. ### Reverse-proxy base path **Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress: - **(server ingress)** `stripBasePath()` runs inside Fastify's `rewriteUrl` so ALL routes stay declared prefix-agnostic (`/api/...`, `/ws/...`) — and a request arriving WITHOUT the prefix (hooks, health checks, docker bridge, all hitting the raw port) is left untouched, so the server answers at both. - **(server egress)** one `onSend` hook prepends the base to every root-absolute `Location` header, covering all redirects. - **(HTML)** `renderIndexHtml` rewrites the shipped `` to the mount and injects `window.__CODEMAN_BASE__` — the template's asset refs are all RELATIVE so `` handles them for free. - **(frontend runtime URLs)** root-absolute URLs ignore ``, so `CodemanBase.url()` (constants.js) is the route builder, applied transparently by a `fetch` wrapper and explicitly at the few EventSource/WebSocket/`window.open`/``-src sites. - **(sw.js/manifest)** the worker derives its base from `self.location`, the manifest uses relative `start_url`/`scope`. - **(web-tab proxy)** `proxyPrefixFor(cap, basePath)` is the single base-aware root that cascades to the injected ``, root-absolute HTML rewrites, the `runtimeUrlShim`, `Set-Cookie` Path and `Location` rebasing — while the INGRESS parsers (`capabilityFromProxyPath`, `resolveUpstreamUrl`) stay base-agnostic because `rewriteUrl` strips the prefix before routing, and `capabilityFromReferer(referer, basePath)` strips it from the browser-supplied Referer. ⚠️ `--base-url` rides the daemon relaunch via `buildWebArgs` and the service unit via `resolveServicePlan`. Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.ts`; HTML injection in `test/render-index-html.test.ts`. ## Session launch modes **Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other. ### 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()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=` from `animations`, `resume `, `--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 ` 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 ` 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 ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `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/` (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 [--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 ` pane report-agent --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///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) before 24.22 and 26.10 (nodejs/node#65865) 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 `` 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, `` and `session-`) 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, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session ` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: it takes the mouse strip instead, because its TUI is fullscreen by default since 1.0.0 (`tuiMode`) and inline with terminal-owned scrollback under `tuiMode: regular`, the default below 1.0.0, so the full strip's `3J` removal would have no owner in the second shape. See "Pi is the same shape" in the scrollback section. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). **OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume `/`--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/`-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 `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`. Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit. ⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk; the one exception is the Edit history store, which keeps the diffs parsed from a remote session's transcripts under `dataPath('edit-history')` (see [Edit history panel](#edit-history-panel)). ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback. ### Remote SSH cases **Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input. **Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases`, cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks and `$` rejected; `extraSshOptions` refuses backticks and `$(` only, so a bare `$` passes there and reaches `ssh` as those literal characters); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id || claude --resume ` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates): `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string: the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths: a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: ` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string: the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside the workspace is `NOT_FOUND`, anything else `OPERATION_FAILED`, never a local fallback), and `fix-plan/write` and `ralph-prompt/write` refuse a remote session (`INVALID_INPUT`) before touching the local disk. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`, `test/routes/ralph-routes-remote.test.ts`. **Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. ⚠️ The bootstrap only works if it reaches the far end verbatim, so a remote launch, and an adopted ssh attach with it, goes to the local `bash -c` single-quoted (`bashCArg()` in tmux-manager.ts): in double quotes the local `sh` expanded `$HOME` and `$PATH` on this host first, and a remote user with another home found no tmux (#760, exit 127). ⚠️ Codeman's stock pane text is POSIX (`"${SHELL:-/bin/sh}"`), so it goes to `/bin/sh -c` as separate tmux arguments: tmux runs a ONE-argument command through the remote default-shell and exec's two or more directly (measured on 3.4), and fish refuses `${…}` with status 127. A `commands.*` override stays one argument, since it is the user's text for their own shell. `test/remote-pane-command-shell.test.ts` pins that split without needing fish. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup. Further wake-on-LAN detail: the wiring guards in `test/remote-wake.test.ts` are two; the second also pins that `server.ts` holds the registry for its LIFETIME only (`drop` on cleanup, `stop` on shutdown) and never calls a waking method. The create wake is wired in the route rather than the shared session service for exactly the no-timer-wakes reason. No `ServerAliveInterval`, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. For a host behind `jumpHost`/`socksProxy`/a `ProxyCommand` option, the probe connects to `host:port`, which such a host does not answer even while ssh works; the registry never gates create/attach on it (`'unprobeable'`), and `/reachability` answers `reachable: null, probeable: false`. The banner keys on a PROVEN `false`, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids). Input arriving during a wake is buffered: a chunk over 4 KB is dropped whole, never delivered as a fragment, and the route answers `{buffered:true}` / `{buffered:true, dropped:true}` so the caller can tell. It is flushed in order after `reattachRemote()` with `fromUser`; a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later. Send-and-wait blocks instead and answers `OPERATION_FAILED` when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything; the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS`, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s `proxy_read_timeout`. The `remote:` SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (`username`) since it has no session yet. `remote-wake.ts` refuses real IO under `VITEST` like `remote-files.ts`. ### Adopted foreign tmux sessions **Adopted sessions** (#510; `src/foreign-tmux.ts` pure, `src/foreign-tmux-discovery.ts` transport, `foreign-sessions.js`, `GET /api/mux/foreign`, `POST /api/sessions/adopt`): a tmux session a human started is listed on the home screen, and one click wraps it in a tab. Like remote-SSH and Docker it is a **LOCATION OVERLAY**, never a `SessionMode`: `Session.adopt` carries the connection, the mode comes from a bounded walk of the pane's process tree, and `Session.isAdopted` asks who launched the process, a different question from `isExternalCliMode()`, which is why the two are never folded together. **The wrapper.** Adoption creates an ordinary `codeman-<8hex>` session on Codeman's own socket whose pane runs the attach, so `killSession` can only ever reach the wrapper and no shape of caller bug reaches the foreign server. The attach joins as a grouped `codeman-view-` session with `status off` and `destroy-unattached on`. ⚠️ `new-session -t` does not fail on a missing target, it invents one (measured on tmux 3.4: joined `work-prod`, or made a fresh shell, or started a server on an empty socket), so an exact `has-session -t '='` guards it. The `=` cannot stand in for that guard on `new-session -t`, where a missing target makes a group literally called `=`. ⚠️ The guard only holds for a name both commands read as a name, so a name `new-session -t` reads as target syntax (`TMUX_TARGET_SYNTAX`: a leading `=`, `@` or `%`, or a bare `~`) is refused at discovery: measured on tmux 3.4, `@1` joined the session holding window `@1` and input typed in the tab reached it, and `=eq` made a new group with a fresh shell. A `command -v tmux` runs first, because the guard fires on any failure and "tmux is not on this PATH" must not read as "your session is gone". The client is `tmux -u`, or a POSIX-locale container garbles the tab, and the probe's `list-panes` is too, or a name reads as underscores the guard then cannot find. A `docker exec` outlives its client, so that view is killed explicitly on close. ⚠️ Nothing is ever set on the foreign server beyond our own view: not `window-size` (a window option on a shared window, it survives our detach) and not the prefix table (server-wide, unlike `nestedPrefixTableResetCommands` on the servers Codeman runs itself). ⚠️ While the view exists an owner's `kill-session` leaves the window alive under it until the tab closes. ⚠️ The wrapper's pane IS the owner's pane, so nothing may be typed into it on start: `startShell()`'s new-session `clear\n` submitted whatever the owner had half-typed (measured) until it was gated on `!this._adopt`, and the trust-dialog auto-accept stays off for the same reason. `shell` is also the classifier's answer for anything it cannot name, such as vim or psql. **Names are the injection surface.** A session name is chosen by someone else and several shells parse the attach line on its way to tmux; in `bash -c ""`, the form it first crossed, `$` and backticks were live. Names and socket paths pass an allowlist at DISCOVERY, so a bad one gets no id and the adopt route, which re-resolves through discovery, can only 404. The ssh probe and attach run in an explicit `sh` behind `withRemoteTmuxPath()`, since the guard chain is POSIX and the login shell may be fish. ⚠️ The attach line reaches `bash -c` single-quoted through `bashCArg()` at both launch sites, the path a remote or docker launch takes (#760), never a bare `JSON.stringify()`, or the ssh attach's `$HOME` and `$PATH` expand on THIS host and a tmux in the remote `~/.local/bin` is found by discovery and missed by the attach. Single quotes also keep a real newline, which JSON escaping left as the two characters `\n`. That closes the first shell only, so the discovery allowlist stays. `test/tmux-manager.test.ts` compares what bash receives with the built line at create and at respawn, for all three locations. Docker discovery spawns `dockerEngineArgv()`, never the shell-quoted `buildDockerBaseArgs()`. **A server that stops answering.** A tmux client never returns from a stopped server, and it hands dups of its stdout to the server, so that server holds our pipe open after the client is dead (measured: SIGTERM ended the client and no EOF came). ⚠️ So a watchdog around `list-panes` cannot work, and the same hold would keep `docker exec` and `ssh` waiting. `buildForeignProbeScript()` pings each socket with `has-session` at `/dev/null` under `FOREIGN_SOCKET_TIMEOUT_SECONDS`, in the script because no signal of ours reaches a client inside a container or across ssh, and only a server that answered gets the real stdout. One that did not prints `CMFH` and costs its own rows and a note. The watchdog reports through its own exit status, since a client ended by SIGTERM exits 0. `runForeignProbe()` runs all three transports in their own process group, signals the group at the deadline, destroys its end of the pipe by hand (or one handle leaks per scan) and keeps the rows it read. ⚠️ Sockets have no count cap: in glob order `codeman*` sorts before `default`, and a box with many instances would never reach the sessions this feature is for (212 sockets measured here). The sweep is bounded by time. **Text from someone else's process.** The parser and the classifier run synchronously on the event loop over what a pane, a container or an ssh host printed, and the row caps do not help when one row is the whole 2 MiB. ⚠️ So every step stays linear in that text: no regex tail over an argv (`launcherPrefixes()` is a scan), `.*` under the `s` flag so `$` cannot fail and hand `\s+` its spaces back, a `Set` for the socket rows, `FOREIGN_MAX_ARGV_CHARS` on what is kept, and one `createForeignSignatureMatcher()` per scan, since every pane of a hostile host can claim the same process tree and repeat the same rows. **One wrapper per target.** `foreignTargetKey()` is the ONE key, read by the adopt route and `adoptedBy`, built from the location, the host identity, the socket and the name: a socket and a name alone collide across containers and hosts. Both read live `Session`s, since the mux record only exists after the first attach. **No workspace.** ⚠️ An adopted session keeps its connection on `adopt.remote`/`adopt.docker`, so `session.remote` and `session.docker` are undefined, and its `workingDir` is the neutral `/tmp`. Code that branches on those two reads an in-container or over-ssh adoption as a local session working in `/tmp`. So anything that treats `workingDir` as the session's workspace must also test `session.isAdopted` (`adopt` on a pure view): uploads, the upload GC, git status, git fetch and its scheduler, the image watcher, Read My Mind's git signals, the Discord folder, `last-response`, guarded input and Ralph's `@fix_plan.md` and `@ralph_prompt.md` routes all do. The file surfaces (Files panel, File Viewer and its edit mode, `html-view`, absolute-path links) are the one standing exception (owner decision 2026-11-06): they still treat `/tmp` and this host's paths as the workspace, even on a container or ssh tab, so such a tab shows this host's files and not the far end's. It gives no privilege: `/tmp` is world-writable anyway, and adoption is admin-only in multi-user mode. Gating them for remote and Docker adoptions, while keeping a local adopted tab's absolute-path links, is the planned follow-up. A claude wrapper reports `statusLineExporter: 'remote'`, which is also what keeps keep-warm off it. **Lifecycle.** `paneExitApplies` is false for a wrapper, so an attach that ended is never `paneExit` and the sweep never closes the tab. ⚠️ `remain-on-exit` still keeps Codeman's own tmux client on the dead pane, where a pid and `busy` hid it and both attach routes refused it, so `detachEndedAdoptedAttaches()` detaches that client on the reconnect watcher's tick: the tab closes nothing, its pid goes null, and selecting it (or "Go to tab" once a session of that name is back) runs the guard again. Boot recovery re-attaches a dead wrapper, which prints that the session is gone and exits. Reboot restore refuses one, since all Codeman ever had was an attach command. ⚠️ A wrapper that cannot start fails the start, and selecting the tab tries it again (`_failAdoptedStart()`). It never takes the direct-PTY fallback, which would run a new local `claude --dangerously-skip-permissions` or a server-account shell in `/tmp` under the foreign session's name, and `runPrompt()` refuses it for the same reason. ### Docker cases **Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the CLI run modes runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` 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-`, 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-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). 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 || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,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 :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/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, 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-` 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 commit`s 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 pause`d 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 ` 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 '':` 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. ⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them 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 `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()` (the one exception is a config-backup restore of `clis.json`, which writes it atomically and calls `reloadCliRegistry()`): 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 — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. A multi-line prompt keeps its line breaks inside that one paste, with or without the Enter (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 /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. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). **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 ]` / `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 on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `reference/.md` through it lands wherever it points (measured: install overwrote a file there, uninstall deleted it). The check runs once before the first write; a link swapped in after it is not covered. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ 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, and since the per-case injection is on by default, a plugin user turns that setting off. Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.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, Codex and Copilot 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 /config.json`); a sign-in kept in the OS keychain is not read, so it shows no row. ⚠️ The read is **on by default**, like the Codex poll: it rides the same `showPlanUsageLimits` collection switch (an absent key reads as ON, so it runs even for a phone-only user whose chip is hidden), and turning Plan Usage Limits off stops the request. ⚠️ `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) holds the three gates in order: the setting, then a token, then `isCopilotAvailable()`, whose resolver ends in a login-shell probe that a host without a Copilot sign-in must never run on each tick; a null clears the row. ⚠️ The token is never logged, stored or broadcast, the request refuses redirects (`redirect: 'error'`, so a redirect cannot carry the token elsewhere), and the row is admin-only on the wire like Codex's (`deriveSseHint`, and the init snapshot's `planUsage`). `plan-usage-latest.ts` merges the three process-wide sources (a Claude statusline sample keeps the `codex` and `copilot` keys) 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/copilot-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()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits. ### Prompt-cache keep-warm **Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and neither is recorded in the run summary (`recordHookEvent()` files a `stop` as a warning, and the Away digest lists warnings under Needs Attention). ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings save that changes the default or the cap (`rescheduleAll()`, compared on the merged settings since the browser sends both keys on every save, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs **Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini/antigravity/pi agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. ### 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_` 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:` rect cache. **Every family is drawn, and the SELECTED tab's is emphasized** (2026-10-07): the family the active tab spawned, plus the family it was spawned into (its parent and siblings), get ``: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn LAST so no other family covers it. A tab that is both a child and a parent emphasizes both; a web tab or a tab without lineage emphasizes nothing. ⚠️ Drawing ONLY the selected family was built first and rejected by the owner the same day ("I wanna see all the connections always but when I select one … make thicker"), so do not reintroduce a filter. ⚠️ Lanes follow STRIP order (`index % CodemanLineage.MAX_LANES`, 3, since a 12px row gap fits three lanes at 3.5px), never the selection: only the draw order changes, so a family's lines never jump when you click a tab; families sharing a lane are told apart by colour. ⚠️ Because the emphasis depends on the selection, `_updateActiveTabImmediate()` redraws whenever any lineage exists (`_lineageTotalEdges > 0`), and the incremental tab render redraws on the same condition (a child's `parentSessionId` or working state can arrive there). `_lineageEdgeCount` counts what is DRAWN and still gates the strip-scroll redraw. ⚠️ **A selection keeps resizing tabs AFTER that redraw**: the active tab reveals its gear and close icons by transitioning their padding (~150ms), which can move tabs or re-wrap a row, so the strip's `transitionend` (size properties only: `width`, `max-width`, `padding*`) triggers one more coalesced redraw. Without it the trunk hung under the tab's old position (measured ~10px off, more after a re-wrap). **Geometry is a tree routed through the gaps** (`computeLineageTree()` + `computeLineageRows()`, constants.js, pure and unit-tested): one rounded orthogonal tree per spawning tab. Every route starts at the PARENT's bottom-center and ends at one child's bottom-center, so a parent's routes share their first stretch exactly and overlay into one trunk with a branch per child; that is also what keeps a working child's dashes in phase with its siblings along the shared part. A route moves horizontally only inside a row gap (the gap under each endpoint's row, at the row's TALLEST tab, since the active tab is 2px taller) and vertically only along a tab's own stem or the SPINE, a channel left of every row that joins the gaps of different rows. In the vertical rail the same idea is one shared track down `--lineage-vertical-gutter`. `test/session-lineage-lines.test.ts` pins "never crosses a tab" as a property over a five-row fixture. ⚠️ **The room is reserved, not borrowed**: `.session-tabs.lineage-tree` (styles.css) adds bottom padding for the last row's gap, and on a wrapped strip a 12px row gap plus a 20px left channel for the spine. `_syncLineageGutter()` toggles it from the top of `updateTabOverflowMode()`, BEFORE the wrap is measured, and keys it on whether ANY family exists, never on the selection: the header's height is the terminal's height, so a class that followed the selection would resize the PTY on every tab switch. ⚠️ **Opacity lives on the family ``, not on the paths**: translucent paths overlapping along the trunk would stack into a brighter line than the branches. Working routes are appended first so an idle sibling's solid stroke covers the shared trunk. Style is deliberately quiet (1.5px, rounded, a 1px dark outline instead of the old double glow): the lines no longer cross text, so they no longer need a glow to survive it, and a dash now means "this child is working". ⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so the lines 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. The sidebar layout draws no lineage. ⚠️ **`data-agent-id="lineage:"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute (nested inside the family group is fine), so tagging them this way is the whole reason the lines get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` scrolls, so a tab scrolled out of the strip still HAS a rect, one lying over the logo or the header buttons; an endpoint outside the strip is SKIPPED (a child is left out, a parent drops its whole family), since 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. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry, focus rule, routing room, colours), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths). History: before the tree, every pair got a U-bridge bezier hanging below the strip, and its dip was mis-tuned in both directions (a 44px cap flattened strip-wide spans into a straight thread, #285; a 104px cap plus the row offset bowed ~106px into the terminal, 2026-08-15). The tree removed the dip entirely. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every line 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 lines 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). ⚠️ The draw pass claims a color for EVERY family in strip order, drawn or not, so which family happens to be selected first never decides who gets the blue. Colors are set inline as `--lineage-color` so styles.css keeps owning weight and 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-` 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-` / `s-`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. **The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input`, the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) and a Discord reply set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session. **The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. **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 `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 by default only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry, because `_nextCaseSessionStartNumber()` numbers from the highest OPEN tab and restarts once a case's tabs are closed. The opt-in synced `pinCliSessionName` (default OFF) pins every non-blank tab name for users who want the CLI labels to match their tabs; it is read fresh at every claude create and respawn (`readPinCliSessionNameEnabled()`, hooks-config.ts, like `readPlanUsageTelemetryEnabled()`), so a toggle applies from the next spawn with no restart. ⚠️ Its cost: Claude stops generating its own titles for those sessions, and a rename holds only until the next turn, because a pane spawned with `--name` re-appends its in-memory title after every turn (`claude-session-title.ts`); a server restart re-attaches the live pane without respawning it. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it. Further detail: the `: ` 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 window of tmux's RENDERED history on every selection, automatic drop recovery, clear and tile load (`SHELL_LOAD_QUERY` in constants.js: `?full=1&tail=1MiB&lines=10000&alt=recording`): a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session (`lines=` bounds the synchronous capture-pane, about 8 ms per 1000 lines). They never replay the raw byte recording ahead of the visible frame any more: it holds full-screen repaints (an ssh'd tmux, `less`, any TUI) drawn for the pane height of the moment, and replayed into a terminal of another height each repaint's last newline scrolls its top row into scrollback, so one line tmux holds once showed up dozens of times. Because `lines=` is shallower than xterm's scrollback, the route reports a cut: the capture's `display-message` query also reads `#{history_size}` (`PaneCaptureOptions.capturedHistoryLines`, the same field the route reports as `paneHistoryLines`), and a full capture whose CLIENT bound (`lines=` below the configured `tmuxHistoryLimit`) left lines in tmux comes back `truncated`, `truncationReason: 'tail'`. ⚠️ Only a client bound below the ceiling counts: a request without `lines=` is bounded by the ceiling itself, and on tmux before 3.7 a live pane can hold more than a lowered ceiling, which no request can read. ⚠️ **A pane in the alternate screen keeps no tmux history**, and a nested tmux client (remote SSH and Docker cases, or `tmux`/`ssh -t host tmux` run by hand) holds its pane there, so its full capture is one screen. The same query reads `#{alternate_on}` (`capturedAlternateOn`), and a request carrying `alt=recording` (only `SHELL_LOAD_QUERY`) is then answered the old way, the visible frame over the tailed byte recording, which still carries the scrollback; agents, scroll pulls and **Load full history** never send it. `#{alternate_on}` cannot tell a nested tmux from vim, `less` or `htop`, so a local shell with one of those open takes the same fallback until it exits (the pre-fix load, with its repaint duplicates). 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`. **The partial-history notice is lazy and pane-gated** (`computeHistoryTruncationNotice()` in constants.js, `_renderHistoryTruncationBanner()` / `_setHistoryNoticeRevealed()` in app.js). `truncated` on a terminal response measures the server's BYTE stream, which is not what `full=1` can return: for a pane with no tmux scrollback (fullscreen claude starts in the alternate screen and never writes to the main grid, so `#{history_size}` is 0) the bytes a tail cut drops are old repaint frames. Measured on prod 2026-10-10, every claude pane: the tab-switch tail cut a 1.2 to 6.2 MB stream to 1 MiB, the local scrollback that tail replays into is repeats of the same frames (one pane: 450 non-blank rows, 44 unique, one row 33 times), and `full=1` returned 1 to 5 KB, so **Load full history** could only end in the downgrade refusal while the banner promised "4.8 MB more". The capture therefore reads `#{history_size}` in the SAME `display-message` as the cursor (no extra tmux call), the route returns it as `paneHistoryLines` (absent when the body is not a capture, and for a remote SSH session, whose local pane hosts the remote tmux client and always reports 0 while `full=1` reads the remote history), and a reported 0 hides the notice in every state; a positive count replaces the byte gap in the message. ⚠️ Key the notice decision on `history_size`, never on `alternate_on`: a shell running `less` is in the alternate screen with its whole history still retained (measured, 99,972 rows) and `full=1` returns all of it. The one place `alternate_on` decides is a shell's AUTOMATIC load (`alt=recording`, see Full-scrollback replay): there the question is whether the capture holds the scrollback a nested tmux client keeps in its byte recording, not whether a pull can return more. The notice is also LAZY: it appears only after a wheel/touch gesture reaches the top of the browser buffer and the pull that gesture started has settled (`_maybeLoadMoreHistoryOnScroll`), goes away when a downward gesture is back at live output or the tab switches (`selectSession` nulls `_historyNoticeRevealedFor` before repainting), and its × is sticky per session until reload (`_historyNoticeDismissed`). Tests: `test/history-truncation-notice.test.ts`, `test/history-notice-reveal.test.ts`. **A tab switch to a pane with no tmux scrollback takes `full=1`, not the 1 MiB tail** (`_paneHistoryLines` + `_notePaneHistory()` in app.js). Every terminal response (select, refresh, re-pull) records `paneHistoryLines` per session, and `selectSession` serves a non-shell switch from `full=1` whenever the last report was 0. For such a pane that capture IS the visible frame (0.9 to 5.6 KB on prod) and matches what a page load shows, while the tail was 1 MiB of old repaints. Measured 2026-10-10 with real prod payloads through the real client in chromium: median switch 1.26 to 1.66 s down to 0.10 to 0.19 s, parse ~1.1 to 1.5 s down to 2 to 3 ms; server time 136 to 1070 ms (tail) vs 89 to 330 ms (full). An empty local buffer is also what lets `_maybePageCliTranscript` send a gesture to the CLI's own transcript, and the same memory is what stops it: a buffer a tail replay left at `baseY === 0` while the pane reports scrollback is unloaded, not hollow, so the gesture scrolls locally and the scroll-to-top pull (`_maybeRefetchFullHistory`) fetches `full=1` (see the hollow-pane rule under wheel forwarding). That pull skips the 60 s useless-pull latch for such a buffer (the latch's premise, a browser holding more than tmux, is gone) and drops a completion whose select generation moved during the fetch, since the session-id check alone cannot see a switch that came back (A to B to A); the refresh path records its count only past its owner check for the same reason, and no caller (the switch, the refresh, the pull) records an error envelope. ⚠️ Only a reported 0 counts: a response without the field FORGETS the entry (unknown is never empty), so an older server or a byte-history fallback keeps the tail; a pane that starts keeping history (claude switched to its inline view) is learned from the next response and goes back to the tail. ⚠️ Shells never take this path. ⚠️ Remote and Docker sessions never take this path: their local pane hosts a nested tmux client in the alternate screen, so it reports 0 while the byte tail carries the real scrolled-off output. ⚠️ The refresh path (`_onSessionNeedsRefresh`) keeps its downgrade guard unchanged: a server-triggered refresh must not wipe local rows the user may be reading. Tests: `test/pane-history-memory.test.ts`, `test/fullscreen-tab-switch-capture.browser.test.ts` (browser suite). **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 without `alt=recording` (every `useFullHistory` pass; only shell loads send it) 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 **Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. **Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex, tracked Copilot or tracked Pi** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. **Pi is the same shape, and the reason it moved to the mouse strip** (fullscreen is the default `tuiMode` since 1.0.0; 1.0.0, 1.1.0, 1.4.0 and 1.5.0 measured in tmux with default settings: `alternate_on=1`, `history_size=0`, `mouse_any_flag=1`, SGR): pi enables mouse tracking ONCE at start. Left on the narrow strip, the wheel worked only until Codeman replayed the buffer (tab switch, reload): the replay carries no DECSETs, xterm's `mouseTrackingMode` fell to `'none'` with `baseY` 0, and the wheel hit an empty local buffer until a resize made pi redraw. Measured on a fresh session: `drag` right after the first select, `none` after a tab switch and after a reload. With the mouse strip the server records the tracking (`cliMouseTracking`), which survives the replay; `_shouldForwardWheelToApp` forwards by hand while it is true. One SGR wheel report moves pi's transcript one row at a low rate, and pi's `fullscreenWheelScrollLines` default accelerates a fast spin. Under `tuiMode: regular`, the default below 1.0.0, pi is inline and emits no mouse DECSET, so the strip finds nothing and the flag stays false; a pi from 0.84.0 set to fullscreen does emit them (0.84.1 and 0.99.2 with `--tui-mode fullscreen` measured: `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`; default settings all 0), and the flag covers that case. ⚠️ Pi deliberately has no `transcriptPageKeys`: fullscreen pi does page on PageUp/PageDown, but pi binds those keys to its editor in regular mode too, where they page nothing and move the caret of a multi-line draft, so the hollow-buffer fallback sent the next keystroke to the wrong line (measured on 1.1.0, 1.4.0 and 1.5.0). The fallback would need a capability to tell the two modes apart, since `_localScrollbackIsHollow` takes no id. The cost: a fullscreen pi with "Wheel scrolls local history" on has no paging fallback, so the wheel does nothing there. That is an accepted exception to "A false gate on a hollow pane must not mean a DEAD gesture" below. Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (Claude ≥ 2.1.187 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_wheelScrollLinesFloat()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. **A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. ⚠️ Pi is an accepted exception: it declares no `transcriptPageKeys`, so a false gate on a fullscreen pi stays dead, for the reason in "Pi is the same shape" above. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()`: the `transcriptPageKeys` capability, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ **`baseY === 0` with a pane that still REPORTS scrollback is not hollow, it is unloaded** (`paneHistoryLines` > 0, the last capture's `#{history_size}`): after a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, which scrolls nothing, so an inline Claude session landed here and the page keys moved its composer cursor instead of the transcript (measured 2026-10-21 on the second select after a restart, until a page reload). That case falls through to local scrolling, whose top-of-buffer pull (`_maybeLoadMoreHistoryOnScroll` to `_maybeRefetchFullHistory`) fetches `full=1`, and the gesture scrolls on from there once the pull lands (a slow response can outlive the gesture; the next one scrolls); 0 and unknown counts keep paging (fullscreen Claude, codex, opencode; a remote pane reports no count and a Docker pane 0). Page keys already queued and the gesture debt are dropped on the way out, since they belong to the abandoned route. The `[scroll]` line prints `paneHistoryLines` next to `localScrollbackRows`, and a count turning positive re-logs, so the two states read apart. The trade-off is accepted: while that pull is in flight or has failed, the gesture scrolls an empty buffer and the next upward gesture after the 4 s cooldown retries it, where paging would have moved the composer. An error envelope (`success: false`) records no count, in the pull, the refresh path and the tab switch alike, so one failed capture does not hand the gesture back to paging. ⚠️ The first event of a gesture pages AT ONCE, for any opening event under one page, and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all, and a mouse sent nothing for its first 5 notches (100 px is 4 rows; 7 on Firefox's 3-line notches, issue #697). An opener of `PAGE_KEY_IMMEDIATE_MAX_ROWS` (2) rows or more is treated as a mouse-wheel notch and tagged `notch`: its debt lasts `PAGE_KEY_NOTCH_DEBT_MS` (3 s) instead of `PAGE_KEY_DEBT_MS`, so slow notches repay one page instead of each paging a full screen; past 3 s of quiet each notch pages. The signal is the opener's size, not the device (a touch contact whose first touchmove is 2 rows or more, or a brisk flick that opens large, is treated the same). A new opener keeps same-direction travel left over from the gesture before, so a run of notches still pages once per half screen of travel. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`), or `PAGE_KEY_NOTCH_DEBT_MS` (3 s) for a notch-sized opener, while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) and slow notches pay the debt back instead of each pre-paying a page; after that much quiet the debt lapses and the next tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch debt, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Four gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), and anything while a Codex selection menu is on the pane's screen (the "Implement this plan?" prompt, the /model, /permissions and /review pickers, a question, an approval, the trust prompt). All of them exist so a page key never lands in an open selector: Codex binds PageUp/PageDown to the SELECTION in its menus (codex-cli 0.164.0 measured: PageDown from option 1 lands on option 3), so a wheel turned into page keys walked the cursor through the options, one notch onto "Yes, implement this plan". The menu is read off the screen by `CodemanTerminalInput.isCodexSelectionMenu` (a selected numbered option above a key-hint footer that is the last thing drawn; an answer that quotes the hints above the composer is not a menu), not from an alert: the plan prompt is deliberately NOT a blocked dialog (`test/codex-blocked-detection.test.ts`), so nothing about blocked state or notifications changes. The primary pane logs the two dialog-like cases as `page-keys-dialog-open` and `page-keys-menu-open` so a wheel dead under a stale alert or an open menu leaves a `[scroll]` line. The alert check and the menu check are the two rules outside the helper, because they are per pane: the primary pane reads `tabAlerts` and its own terminal for `activeSessionId`, a tile its own `sessionId` and terminal (usually not the active ones; `_codexMenuOpen(target)`). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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|page-keys-dialog-open|page-keys-menu-open|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. **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. ⚠️ **The close is optimistic**: `closeSession()` runs `_onSessionDeleted({ id })` itself (the split, tile, detached-window, WebSocket and per-session teardown, idempotent so the real broadcast finds nothing left), does the follow-up selection and renders the strip with `renderSessionTabs({ immediate: true })` BEFORE sending the DELETE; the server's kill takes a few hundred ms and a tab that sat there that long read as a dead button. `_onSessionCreated`/`_onSessionUpdated` ignore ids in `_closingSessions`, so no upsert brings the row back mid-request. `_apiDelete` resolves a Response (or `null`), never throws: OK or 404 is closed; anything else is checked with `GET /api/sessions/:id` (a delete can land and lose its reply, after its broadcast was already spent) and only a session that still exists gets its row back at its old index, with the error toast. 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`. ⚠️ **The tab's badge is translated (zh-CN), so its updates compare with English, never the DOM.** `applyPaneExitBadge()` remembers the last English badge text (`data-label`) and the tab's accessible name (`data-aria-source`), both also seeded by the full render, and writes only when those change: the translator rewrites both in zh-CN, and a DOM compare (or an unconditional set) would write the English back on every incremental pass for it to translate again. i18n.js has `exited` and patterns for `exited (N)`, `exited (signal N)` and `<name> session, agent exited ...` (the name passed through); `test/i18n-exit-run-help.test.ts` covers every `paneExitLabel()` form. **A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close: - ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep. - **At least `CLEAN_EXIT_CONFIRMING_READS` (2) authoritative reads must agree.** `TmuxManager.getPaneExitReadCount()` counts them. A repeat of the same pane pid, status and signal adds one, anything else starts again at 1, and a failed, empty or skipped read never reaches `applyPaneExits()`, so it neither confirms nor resets. A mux without the method never has a session closed. - **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay. - **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand. Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.codeman-uploads` (and the pre-move `.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 workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. ⚠️ A third, optional `workDetect` field, `watchingLine` (plus `watchingLines`), tells a quiet pane that is still RUNNING something apart from one that wants a human; it is read from the same idle-confirmation capture. See [The watching signal](#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). ⚠️ A fourth, optional `workDetect` field, `awaitingLine`, makes a turn that ENDED waiting for workers the CLI started count as WORKING: with background agents or an ultracode workflow still running at turn end, Claude closes the turn with `✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when they report back, so the pane is quiet with the composer up and every other signal read it as idle (reported 2026-09-28 on a live 2.1.283 session). `_probePaneWorking()` ORs it into the same capture. ⚠️ It must never be searched across the pane like `workingLine`: Claude renders the row from a `useState` snapshot taken at turn end and never redraws it, so the words stay on screen after the workers finish and a pane-wide match would pin the tab busy until they scroll off. `isAwaitingWorkers()` (pure, `session-activity.ts`) finds the composer (the LAST row carrying `promptGlyph`, bare or boxed), walks up at most `AWAITING_SEARCH_ROWS` past blank, box-drawn and indented rows, and tests only the first column-0 row. ⚠️ Keep the pattern anchored on column 0 (`^✻`): Claude's own rows start there and the agent's prose never does, which is what stops an agent from holding its own session busy. It is deliberately NOT tested on the PTY stream, where redraws of the stale row would re-mark working. Tests: `test/session-awaiting-workers.test.ts`. ⚠️ A fifth, optional `workDetect` field, `blockedLine` (plus `blockedLines`), marks a pane showing a dialog the CLI is blocked on, and it WINS over the working line in the same capture (codex's question dialog carries `esc to interrupt`, its working line, in its own key hints). See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). ### The watching signal (a quiet pane that is not waiting for you) **A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ A capture that FAILS clears the label (and announces the change) rather than keeping the last one: a stale label opens the next idle prompt already acknowledged, so keeping it would turn a failed `capture-pane` into a missed alert, while clearing it costs at most an alert the next readable capture takes back. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen. **The fix is the alert that does not fire; the badge is cosmetic.** `hook-event-routes` passes the label to `notePrompt()`, which opens the idle item ALREADY acknowledged (`acknowledgedAt` + `acknowledgedReason`). Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", so the item stays pending, answerable and available as Read My Mind context, and a wrong label costs a card that does not blink rather than an alert that was never created. Every surface follows from that one flag — the broadcast carries `acknowledgedReason` so a live page declines to arm (`_onHookIdlePrompt`, settings-ui.js), the push is skipped, a reloading page reads `acknowledgedAt` in `seedApprovals()` as it always did, `classifySession()` and `pendingApprovalCount()` (tui-model.ts, tui-render.ts) ignore an acknowledged item, and the TUI card drops to the `info` tone and says why. It re-arms for free: the next idle prompt supersedes the item and is built fresh. ⚠️ Only `idle` is eligible, so a permission or question dialog still goes red whatever else the agent started — but a prose question is NOT a dialog, so an agent that arms a monitor and then asks "which branch?" in plain text is silenced along with the false alarms. That is the accepted cost of the design and the reason the kind gate sits at the single place items are created. ⚠️ **A chip that waits on a HUMAN must never count as watching**: Claude's Artifact comment monitor (an agent that published a page and hears nothing until somebody comments) is refused for the whole row by a `^`-anchored negative lookahead keyed on "Artifact" alone, so a footer cut off mid-chip is refused too, and the `^` is what stops the engine retrying past the chip and reporting a shell beside it. **⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both: its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'`: no IDLE prompt from a codex session reaches `notePrompt()` (its screen-read dialogs open PERMISSION items, which a watching label never acknowledges), so a forged label costs a wrong badge and nothing else, and a CLI that gains idle 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). ### Screen-read blocked dialogs (codex) **Codex is the one CLI Codeman launches with its approvals ON by default, and it has no hook to say it stopped for one** (`hooks: 'none'`), so a codex session parked on "Would you like to run the following command?" read as plainly idle, and one parked on a question as WORKING. `capabilities.workDetect.blockedLine` (optional, per CLI, plus `blockedLines`, 1 to 24, default 1) names the dialog's chrome; `_probePaneWorking()` reads it off the same capture the idle confirmation takes (`_paneShowsBlocked()` → `isBlockedScreen()`, pure, `session-activity.ts`: the last `blockedLines` non-blank rows joined with `\n`, tested once, so a pattern can demand several markers and end on `$`), and `Session` emits `blockedChanged`. `session-listener-wiring.ts` hands that to `web/screen-blocked.ts`, which raises EXACTLY what a Claude `permission_prompt` hook raises (`sessionWaits.notifySignal('blocked')`, an Approvals Inbox item, the `hook:permission_prompt` broadcast with `source: 'screen'`, push and webhook, a run-summary row), so the tab alert, phone overview, TUI and every other consumer needed no second code path; clearing resolves the item it opened, and `approval:resolved` clears every client. ⚠️ **ALERT ONLY, never a keystroke.** The text is pane-derived and the agent writes most of the pane, so the pattern must end on chrome only the CLI draws at the FOOT of the screen, where a dialog replaces the composer (codex's: a `› N.` option row, then its key-hint row as the last row, allowing for that row to wrap; a transcript that quotes every word still has codex's column-0 composer row below it). The item is `answerInTerminal` (#610's shape), the answer route refuses it, push carries no `approvalId`, and Codeman never answers such a dialog itself, so a forged match costs a false alert and nothing more. The message every surface shows is built from the CLI's registry label, never from the screen (a question's text and a command preview are the agent's to choose). ⚠️ **Raise on one reading, clear on two, and keep reading while it is up.** The raise needs no timer: a dialog is drawn at the END of a burst of output, so the settle probe (pane quiet, then capture) sees it. The clear does: answering resumes the turn, a busy pane never reaches the settle path again until the turn ends, and the red alert outlived the dialog by minutes, so `_blockedRecheckTimer` re-reads the screen every `BLOCKED_RECHECK_MS` (2 s, one `capture-pane`, only while a dialog is up; cleared in `_clearAllTimers()`). ⚠️ That read is a synchronous exec (about 8 ms of blocked event loop, measured 2026-10-14) and a dialog can sit unanswered overnight, so the timer SKIPS it while the pane is quiet: a dialog leaves the screen only through a repaint, which reaches the session as PTY output, so nothing drawn since the last reading that saw the dialog (`_lastActivityAt < _blockedSeenAt`) means it is still up; a safety-net read still runs every `BLOCKED_QUIET_RECHECK_MS` (30 s). Never once a reading has MISSED the dialog (`_blockedGoneReads > 0`): tmux updates the pane before it sends this client the repaint, and the confirming read must not wait for output. It clears after `BLOCKED_CLEAR_READS` (2) readable captures in a row without the dialog, because one capture can land inside a full-frame repaint, and a clear followed by a re-raise sends the alert, push and webhook twice for one dialog. An unreadable capture (`null`) changes nothing in either direction. A new pane (`_resetBuffers()`) and a PTY exit clear it, announced, while the listeners are still attached. The working probe decides blocked-over-working from THIS capture, never from `_screenBlocked`, which lags a vanished dialog by design. ⚠️ **Hooks win.** `raiseScreenBlocked()` does nothing for a session whose CLI passes `hooksAvailableForMode()`, so a CLI that declared both could never alert twice for one dialog; and the inbox item gets no `capture` callback (only the frame, as context), because the inbox's option parser and staleness sweep are shaped for Claude's dialogs and the SESSION owns this item's lifetime. ⚠️ Codex's `Implement this plan?` after a plan turn is deliberately not matched: its `enter select · esc back` footer is the same as `/model`, `/permissions` and `/review`, menus the user opened at the keyboard, and the plan turn has ended there. Measured fixtures (codex-cli 0.162.0, 2026-10-14, widths 200 to 36): `test/fixtures/codex-dialogs/`. Tests: `test/codex-blocked-detection.test.ts` (the pattern and the session state), `test/screen-blocked.test.ts` (what the server raises), the codex blocks of `test/routes/session-wait-routes.test.ts` and `test/routes/approval-routes.test.ts`. ### 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). ⚠️ For the same reason a read the `TRUST_DIALOG_RETRY_MS` throttle refuses is DEFERRED onto that timer, never dropped: the dialog's own paint is often the last output a launching pane produces and lands inside the throttle of the read the blank screen before it triggered, so dropping it left 2 of 12 fresh launches on Claude Code 2.1.294 sitting on the dialog with no keystroke ever sent. And a stopped session never scans: killing the pane produces output, the buffer tail still holds the dialog, and a deleted session went on scanning it and arming the timer for several seconds. ### Owner tab layouts **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. ⚠️ **The frontend READS and EDITS the layout.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections and edits it. The rules that keep it safe: - **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (newest-wins via `createLoadCoordinator`, retried at 5/10/20/40 s and then left to the next SSE init or `tab:layoutChanged`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. ⚠️ Adopting a layout rebuilds the strip ONLY when the structure key changed (`_applyTabLayout`): the server announces a change on every session create/close and order PUT, and the key deliberately leaves the layout version out, so those announcements cost a flat rail nothing. - **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`). - **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption). A store that throws means all-expanded; a malformed stored VALUE reads as empty and is rewritten, so it can never disable collapse on that device for good. A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. The one exception is the rail's **Search sessions** box (#580): while it holds text `_projectTabGroups()` passes `collapsedGroupIds: []`, so every group is DRAWN open and a match inside a collapsed one can show, and `toggleTabGroupCollapsed()` refuses; the stored set and its localStorage key are never written, so clearing the search puts the collapse back exactly as it was (see Session list layout, the rail search). - **A collapsed header carries the most urgent alert it hides** (`hiddenGroupAlerts()`, applied by `_syncTabGroupHeaderAlerts` on BOTH render paths, since alerts change without a rebuild), in the tab alert language: `tab-alert-action` red, `tab-alert-idle` yellow. A permission prompt behind a collapse must never be invisible. - **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. - **The upstream HTML5 drag stays off in the grouped rail**: a flat-order drop cannot express a group move, and the server re-ranks within the old group. The grouped rail has its OWN pointer drag instead (`_bindTabLayoutPointerDrag`, mouse/pen only, bound once on the container): rows move before/after a row or into a group, a header drag reorders groups, and the drop maps to ONE operation through the pure `dropOperation()`. Escape cancels a drag, and the click that ends one is swallowed. ⚠️ A press is only captured once it moves 6 px, so its release can land outside the rail: `pointerup`/`pointercancel` are heard on `window` while a press is pending, a move with the primary button up cancels it, and a new press cancels any previous one. Without that a stale press became a phantom drag on the next hover, and a replaced drag left its capture-phase Escape listener behind, swallowing every Escape before the terminal saw it. The flat rail and the header strip keep the HTML5 drag untouched. For the same reason Ctrl+Shift+{ / } only swaps with a neighbour in the active session's own section (`_canSwapActiveTabWith`, reading the projection's `sectionByRef`): a cross-group swap moves nothing on the server, gets no `session:orderChanged` back, and would leave this client's `sessionOrder` and Alt+N targets out of step with every other device. - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). With **New Session Button on Tabs** on (`showTabNewButton`, per device, `html.tabs-show-new`), every session row menu (the rail, compact or full, the expanded sidebar, the tree; never a tile's, which shares the function) also offers "New session here…", which opens the row's "+" picker, and the compact rail hides it. The "+" itself opens its picker on Enter or Space through its own inline `onkeydown` (the strip's key handler acts only on a focused tab), which is the keyboard path on the header strip, where no row menu exists. The menu cancels a REPEATED Enter or Space, so a held key that opened it cannot also pick the entry it focuses. A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus moving to an element outside it, a resize, a second open and any full re-render. ⚠️ A `focusout` with no `relatedTarget` is not a departure: Safari never focuses a clicked button, so pressing an item blurs the focused one with focus going nowhere, and closing on that dropped the item's click (the tile count menu's rule). The tab "+" picker is this same menu (`_openTabLayoutMenu` with `anyLayout`, so it opens on every layout, not only the editable grouped rail). It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; a tab's "+" (`openTabNewSessionMenu`) runs ONE launch in that tab's case or managed worktree through `_launchOnce` (the Git worktrees modal's launcher too), `run({ count: 1, mode, caseName | worktree, tabGroupId? })`, carrying the tab's named group if it has one. ⚠️ The mode rides the call and is never swapped into the Run button's `_runMode`, so a mode picked while the launch is pending is kept, and the case-list reload after every launch keeps the toolbar's own case (`_reloadCasesAfterRun`): the no-argument reload would re-select the server's `lastUsedCase`, which a "+" launching through quick-start moves to the tab's case while the toolbar stays put, so a later worktree launch, plain Run or group "New session" would carry the toolbar there. ⚠️ The tab's case is found by what the session runs on (`_tabCaseOf`), never by path alone: `GET /api/cases` gives remote and Docker cases a DISPLAY path with the raw values under `remote`/`docker`, so a Docker session belongs to its container's case on its own engine host, a remote one to the case on its own host whose path encloses its own, and a local one to the deepest case enclosing its folder (a display path never encloses one). A Docker tab's picker starts that container's probe (`_probeDockerCaseModes`) when nothing is cached for its case, since the toolbar probes only its own selected case: that open lists what is already known, the next one the container's answer. From an ungrouped tab the "+" sends no `tabGroupId` on purpose (the create schema cannot say "Ungrouped"), so the case default group below applies, as for a toolbar Run. Every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. - **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). ⚠️ **`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, and skips one that does not answer, such as a linked case on an unreachable mount, without waiting on it: `applyWorkspaceHooks` asks the bounded path probe, retries one capacity-refused probe past the bulk cap, and skips whatever is still "unknown" rather than touching it with an unbounded `lstat`). 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 upload dirs (`.codeman-uploads`, and the pre-move `.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 reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`. **The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. The anchors the walk builds carry `dir="ltr"`, so a path at the head of a right-to-left sentence does not decide that sentence's direction; a rewritten link keeps no `dir`, since its label is prose (#715). ⚠️ `_applyBlockDirection()` runs after the linkifier in both consumers, never before it: a block already carrying `dir="auto"` re-resolves its direction on every text node replaced under it. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite, `test/response-viewer-rtl.test.ts` the direction and the order. **Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, except an absolute path inside a followed worktree outside `workingDir` (see Files panel in a worktree), which they serve as a second root. For every other path that is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term. ⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. **The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, except that `_isExternalPreviewPath` and `file-content`/`file-raw` also admit an absolute path inside a followed outside worktree (`outsideWorktree` in session state), so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`. Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. Parentheses also bound an inline spaced path, such as `Updated (/mnt/c/My Docs/report.conf)`. The whole candidate is reserved before fragment detectors run; existing root, prose and traversal guards still apply. Inside parentheses only, a comma at the end of an earlier word marks a list, not one filename. Own-line, list-marker and labelled paths retain commas in filenames and directory names. ⚠️ `Bash(` is the one call shape left out (`opensBashCall()`, both here and for the hard-wrap alternative below): it holds a command, so `Bash(/tmp/a.out input.txt)` is a program and its argument and keeps master's links, while the path tools (`Read(`, `Update(`, `Write(`) link a spaced name whole. Hard-wrapped filenames keep the existing concatenation without inserted spaces. A parenthesised final filename broken between words also carries a spaced alternative without changing its cell mapping. On activation only, the terminal checks the original path through the existing guarded attachment registration route (`notify: false`), tries the alternative only after HTTP 404, and retains the clicked session throughout. If both exist, the original wins. Every original result other than HTTP 404 goes to the normal opener, preserving workspace previews and log viewing even for extensions the attachment registry rejects; for an external path that opener retains the same attachment guards and errors. After an original 404, a spaced alternative opens only if it registers successfully; if it also returns 404, the original goes to the normal opener. Path labels, list markers and leading indentation inside parentheses are excluded from the alternative’s lookup key. Hover performs no lookup. Text alone cannot distinguish the two filenames, so this uses the filesystem result rather than a spacing guess. The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. **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 one place HTML renders is `/html-view/<cap>/...`** (`html-view-routes.ts`, `html-view-capabilities.ts`), never these routes: `POST /api/sessions/:id/html-view` mints a capability for the HTML file's own directory, and every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin`, so the page is opaque-origin even as a top-level tab and cannot read Codeman or drive its API. ⚠️ **Network access is open, by the owner's decision (2026-10-13, #547):** `HTML_VIEW_CSP` is the `sandbox` directive alone, which REPLACES Codeman's own CSP, with no fetch directive, so a page loads scripts, styles, fonts, images and media from anywhere and may fetch, XHR, open WebSockets, send beacons and post forms anywhere, like a file opened from disk. It can read its own folder through the capability and nothing else on this server; it cannot use Codeman's origin, cookies or API, or reach other folders. Never add a fetch directive or `allow-same-origin` without the owner; the exact header is pinned in `test/routes/html-view-routes.test.ts`. The directory is served recursively to the page's scripts (non-dot, asset-allowlisted, realpath-confined, blocklist applied, GET/HEAD only; the dot and extension rules run on the requested name AND on the symlink-resolved target, or a `cfg.json -> .git/config` link would serve the git config), which is why minting is refused for an HTML file in a hidden directory (for a file inside the workspace, a hidden directory below the workspace root, so a workspace that itself lives under one still renders) or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and `$TMPDIR` (named literally, since macOS sets `TMPDIR` in every terminal), the cases and user-space roots, a drive's mount point such as WSL's `/mnt/d`) or in any directory above one of them (`/home`, `/Users`, macOS's `/private`; `isBroadHtmlViewRoot`). A capability minted for a folder outside the workspace records that, and the serving route refuses it per request once `attachmentConfineToWorkspace` is on, the same re-check the attachment serve path makes. The preview renders it by default; the **Page** pill (`htmlRendered`, per-device like `mdRendered`) and any refused mint fall through to the source view with its Edit button. Tests: `test/routes/html-view-routes.test.ts`, the html-view block in `test/webview-auth-exemption.test.ts`, `test/webview-capability-revocation.test.ts`. 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. **XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a `<mergeCell>`, per address of a `<dataValidation sqref>` and per index up to `<col max>`, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `<col>` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `<sheet>` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet<N>.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads `<mergeCell>`/`<col>` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `<row>` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each `<row>`'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `<row r="50000000">` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `<row r>` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `<sheet>` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `<sheets>` container out). The counter also counts every `<xf>` in styles.xml with no list-tracking state (a `</cellXfs>` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk, since `<` can never appear inside an attribute value, and a central-directory `compressedSize` that runs past the file is refused, since the ratio cap divides by it. Fixtures for each are in `test/spreadsheet-preview-worker.test.ts` and `test/spreadsheet-xlsx-core.test.ts`. ⚠️ `sendTile` must never read `sheet.model`, which rebuilds every row and cell model (10-19 ms per tile on a 100k-cell sheet, once per animation frame while scrolling): each sheet's merges are read once in `worksheetMetadata` and kept in `mergesById` next to `populatedRowsById` (replaced on load, cleared on dispose), and a worker test makes the `model` getter throw before requesting a tile. ⚠️ The COLUMN index a cell claims costs the same way: ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only cell sits in `XFD` is a dictionary-mode array, and ExcelJS's `row.eachCell` (`_cells.forEach`) and `row.hasValues` (which `sheet.eachRow` calls) visit every index up to 16,384, about 0.5 ms per row (10,000 such rows took 13 s to load in Chromium, and each tile paid it again; `XFD` is a legal column, so admission cannot refuse it). `worksheetMetadata` therefore never calls ExcelJS's dense `eachRow`/`eachCell`: `populatedRowIndex()` walks `Object.keys(sheet._rows)` and, per row, `Object.keys(row._cells)`, sorted numerically, skipping falsy and `ValueType.Null` cells exactly as `eachCell({ includeEmpty: false })` does and keeping a row only when it holds one such cell (ExcelJS's `hasValues`). Styles, the extent and row heights come from that one pass, merges from `sheet._merges` (the map the `model` getter itself lists), and the per-row cell lists are what `populatedRowsById` keeps, so `sendTile` reads a row's cells from the index instead of `row.eachCell`. Worker tests make `eachRow`, `eachCell`, `hasValues` and the `model` getter throw across load and a tile, and load and tile 3,000 one-`XFD`-cell rows. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters of the decoded XML; real themes are under 10 KB): its `clrScheme` and slot patterns rescan to the end of the text for every unclosed opening tag, and a 1 MB theme of repeated `<a:clrScheme>` took 21.8 s in Chromium. Number formats cap decimals at 30, as Excel does, since `toLocaleString` throws above 100. ⚠️ Merges are also capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) and across the workbook (`LIMITS.maxMerges`, 10,000), both refused as `merge-limit` before ExcelJS loads: on top of the area each merge costs, ExcelJS's `Worksheet._mergeCellsInternal` checks every new merge against every earlier one on its sheet (`_.each(this._merges)`, rebuilding `Object.keys` per call), so a sheet's cost is quadratic in its merge count (one sheet at the old 5,000 cap took 1.6 s in the worker harness, and 20 such sheets ran past the 20 s load timeout in Chromium; the new caps keep the worst case near 1.3 s). ⚠️ The styles counter reads every `<numFmt>` (cellXfs' and dxfs' alike; the lookahead skips `<numFmts>`) with `readTagAttributes()` and refuses, as `number-format`, a `formatCode` that is over 255 characters (Excel's own limit) or has a `[` after its last `]`, checked on the value as ExcelJS's XML parser hands it over (entities and character references decoded, so `[` cannot hide a `[`; ExcelJS's own backslash unescape only removes characters, so it cannot reopen a bracket). ExcelJS runs `utils.isDateFmt` once per numeric cell at load, and its `fmt.replace(/\[[^\]]*]/g, '')` rescans to the end of the code for every `[` with no later `]` (a 60,000-character code of `[` cost 2.2 s per numeric cell; even 255 unclosed characters add up at the cell cap), while a closed code is linear. The length cap also bounds the `Unsupported number format: <code>` notice, and the refusal messages never echo the code. ⚠️ Cell text reaching the page is bounded in the worker: `formatCellValue` caps every display string at `LIMITS.maxCellTextChars` (1,000; the last kept character is an ellipsis and a cut never splits a surrogate pair) for every value shape, strings, rich text (runs are joined only until the cap is passed), hyperlink text, formula source and results, and errors. Without it each tile cell carried its whole string and structured clone copied it once per cell, after the load timeout had already been cleared by the metadata reply: one 1 MB shared string over a 60 x 20 block left the page unresponsive for 150 s at 9.6 GB. A cell is one `nowrap` line with an ellipsis, so nothing past the column width was ever shown. A worker test asserts every tile cell's `text` is within the cap for a long shared string, rich text and a hyperlink. ⚠️ Bounding the text kept is not enough for rich text: `richTextPrefix` also visits at most `LIMITS.maxCellTextChars + 1` runs, since any that many non-empty runs already pass the cap. An empty run (`<r/>`, 4 bytes) adds no text, so a loop that stopped only on length walked every run, and ExcelJS hands every cell referencing a shared string the same `richText` array, so the walk repeated per cell on every tile: one shared string of 200,000 empty runs (a 96 KB file) referenced by a 50 x 50 block took 11.2 s per tile, a million runs 56 s, with tiles requested once per animation frame while scrolling. With the bound the same tiles take about 24 ms. A core test gives a 5,000-run array whose entries past the bound throw on read and asserts `formatCellValue` returns. ⚠️ Per-tag counters can never be complete, so admission also budgets the elements themselves: `createXmlCounter` counts every start tag (`<` followed by a letter or `_`, on the same chunk-carried text the other counters read) in EVERY entry except the ones ExcelJS keeps as raw bytes, exactly the resolved names matching `^xl/media/<alnum>.<ext 3-4>$` (ExcelJS's own patterns are unanchored, so `xl/media/xl/drawings/a.xml` is still parsed as a drawing and is counted), into `counts.elements`, and refuses above `LIMITS.maxElements` (2,000,000) as `element-limit`. ExcelJS builds one object per element in every part it parses: each `<r>` run and `<si>` in `xl/sharedStrings.xml`, each `<font>`, `<fill>` and `<border>` in styles, comments, drawings, VML and tables, which only the inflated-byte caps bounded, and the 100:1 ratio cap is no help because empty stored deflate blocks (5 bytes, inflating to nothing) pad a stream to any ratio. One shared string of 8.3M empty `<r/>` runs (a 375 KB file) took 4.7 s and about 570 MB of heap; 4.6M `<font/>` in styles about 9 s and 1 GB. A three-sheet 249k-cell workbook with rich text has 589k to 839k elements, well inside the budget. A tag inside an XML comment is counted too, which errs toward refusing. Parts that read no attributes (everything but worksheets, `xl/styles.xml` and `xl/workbook.xml`) carry only a trailing `<` between chunks, so a long text node or a binary part is never mistaken for an oversized tag. A worker fixture of 2,000,001 empty runs, padded past the ratio cap and proven to pass every other cap, is refused before ExcelJS is imported. Notices are bounded too: the worker folds two or more `Unsupported number format` warnings in a tile into one `N unsupported number formats` entry (`foldWarnings`), and `.spreadsheet-preview-notice` has a `max-height` and scrolls, so a sheet with a distinct code per cell cannot push the grid out of view. The worker is a stable URL cache-busted by `SPREADSHEET_ASSET_VERSION` in `spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core. ⚠️ **ExcelJS follows a worksheet's comment, comment-note (VML), table and drawing links UNCHECKED, so the worker repairs its parsed model before it links it.** ExcelJS 4.4.0's `reconcile` looks each relationship `Target` up verbatim in maps keyed by its own spelling (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, `drawing1`) and dereferences the result. openpyxl, which most agents write spreadsheets with, uses absolute Targets, its own part names (`/xl/comments/comment1.xml`, which ExcelJS never even loads) and a drawing XML ExcelJS parses to nothing, so a workbook with ONE comment, table or chart threw (`reading 'comments'`, `'name'`, `'anchors'`) and the whole preview failed. Right after `new ExcelJS.Workbook()` the worker wraps `workbook.xlsx.reconcile` (which `load()` calls once on the parsed model) to run `repairExcelJsModel()` (core) first: it re-points a link to the key ExcelJS stored for the same part (openpyxl's `/xl/tables/table1.xml` becomes `../tables/table1.xml`, so the table is kept), and drops a link, `<tablePart>`, `<drawing>` or `<hyperlink>` reference, or parsed-to-nothing drawing/table that would dereference nothing. It never adds anything, looks keys up as own properties only (a `__proto__` Target resolves to nothing), and the preview shows none of these parts. The wrap throws if a future ExcelJS renames `reconcile`. Real openpyxl 3.1.5 fixtures (`test/fixtures/xlsx/openpyxl-{comment,table,chart}.xlsx`) fail the worker tests without it. ### Filesystem path picker **Filesystem path picker** (every Add Case "Browse" button: Link Existing, Create New's custom folder, Clone Repo, Docker; + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file (Docker's Container Workdir picker passes its own `fetchListing` over `POST /api/docker-cases/browse` instead, admin-only, which always lists dot entries and skips the allowlist, blocked-tree and ownership rules below). It starts at the form field's value or, for the `📁 Path` key, the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root) and hides dot entries. A Browse fills its form field; the `📁 Path` key inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. ⚠️ **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`: 1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check 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. 2. `Home` and `CASES_DIR` were unconditional roots. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is **inside `homedir()`**, so a `Home` root alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets only `My Space` (their own `userSpacePath`) plus anything in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is 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 file contents** (the Files panel's delete, [below](#files-panel-delete), removes whole files and borrows these confinement rules), 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; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere 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=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before 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-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick. - **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'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's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because 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). ### Files panel delete **Files panel delete** (salvaged from contributor PR #529, which was closed; its upload half was dropped because `.codeman-uploads/` and the Attach button already cover it): a × on each file row of the Files panel, tree and search results alike, backed by `DELETE /api/sessions/:id/files?path=` in `file-routes.ts`. It removes ONE regular file and is the only file surface that deletes, so it takes the edit route's confinement whole and adds a rule of its own. - **Order of checks**: `findSessionOrFail` (ownership; a foreign session is a 404 in multi-user mode) → remote refused (400, before any path check, for the same reason as the edit route: `validateSessionFilePath` resolves against the LOCAL filesystem, so a same-named local folder would take the delete) → adopted refused (400) → a missing or repeated `path` (400) → a bounded probe of `workingDir`, and of the outside worktree root for an absolute path (#516; `422` on a mount that does not answer) → `validateLocalSessionFilePath(await withGuardedOutsideWorktree(...))` (realpath + boundary, an absolute path inside a followed outside worktree confined to it, the attachment guard re-read per request; any escape, a dangling link and a missing file are all 404) → sensitive / blocked trees / `.git` (403), each tested against BOTH the realpath and the path as named, so a `.git` reached through a linked folder and a linked `.git` are both caught → the link rule below → not a regular file (a folder: 400). - **A symbolic link is refused, never followed.** The confinement helper returns the REALPATH, which for a link is its target. #529 unlinked that path, so `DELETE ?path=AGENTS.md` with `AGENTS.md -> CLAUDE.md` deleted `CLAUDE.md` and left `AGENTS.md` dangling. The route therefore `lstat`s the path as the client named it (`resolve(workingDir, path)`, which keeps an absolute outside-worktree path as it is) and answers 400 for a link. The listing shows a link to a folder as a file row, so it gets a button, and the same 400 answers it. Deleting the link itself was the other option (confine the PARENT's realpath, unlink the link); it was not taken because refusing has no way to touch the target at all. - **The file checked is the file deleted.** A path whose last part is not a link has a realpath naming the same inode, so the route `lstat`s the realpath again and refuses with 409 if the inode, the device or the file type changed in between. `unlink` never follows a final symlink, so a link swapped in after that still removes only the link. A parent folder swapped for a link between the realpath and the `unlink` is the same check-then-use window every file route accepts (an agent running as the same user can delete the file anyway). - **Adopted and Docker.** An adopted session's folder is the neutral `/tmp`. The older file surfaces keep serving it as a standing exception (root `CLAUDE.md`, Adopted tmux sessions), which is not copied to a new surface, least of all one that deletes; the prompt-upload route refuses adopted sessions the same way. A Docker case workspace is bind-mounted at the same path and an `unlink` runs no hooks or filters (the reason git is kept away from those folders), so the route treats it like the edit route does: allowed. - **Frontend.** The button carries no inline handler: `encodeURIComponent` leaves `'` alone, so #529's `onclick="...decodeURIComponent('<path>')"` let a file named `x'-alert(1)-'.txt` run script in Codeman's origin, and `it's.txt` was a syntax error. It is bound in the existing `.file-tree-item` loops (`renderFileBrowserTree`, `_renderFileBrowserSearchResults`) from the row's escaped `data-path`, with `stopPropagation` so the row does not also open the preview. `deleteFileFromBrowser()` asks with `confirm()` (the panel's other destructive actions use the same native dialog, translated by i18n.js's wrapper) and reloads through `_reloadFileBrowserKeepingQuery()`, which re-runs a search in the box, so a row deleted from search results leaves them. The row buttons stay visible under `@media (hover: none)`, where the old hover-only rule meant the first tap opened the preview instead. `_fileBrowserCanDelete()` draws no button for a remote or an adopted session (`session.remote` is undefined on an adopted one, so `session.adopt` is tested too). Tests: `test/routes/file-delete-routes.test.ts` (unmocked fs again: the symlink defect only shows against a real link), `test/file-delete-ui.test.ts` (jsdom with `runScripts: 'dangerously'`, so the old inline handler would really run there, and the real `escapeHtml` from constants.js). ### File Viewer text view: rendered markdown and text toggles **Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline. - ⚠️ **One pipeline, one delegate.** The viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the `sanitize-html.js` allowlist) and binds `_bindResponseViewerInteractions()` on `#filePreviewBody` (container-bound and idempotent, so once per page) for the code-block copy buttons and `a.rv-path` opening. Never a second parser, never a second click handler for the same markup. `breaks` is the one option that differs: chat keeps the default `true` (a newline is the agent's line break), while a file passes `false`, because a README hard-wrapped at 80 columns would otherwise render every wrap as a `<br>`, unlike GitHub's file view. - ⚠️ **Build inside a `<template>`, then rebase.** A detached div whose `innerHTML` is set starts fetching every `<img src>` at once, so the document's relative image paths would hit the server as `/docs/img.png` 404s before being rewritten. `_rebaseFilePreviewMarkdownRefs()` runs on the template content: relative images go to the workspace-confined `file-raw` under the document's directory, root-relative ones (`/docs/x.png`) under the workspace root as on GitHub (the server refuses escapes, so `..` is forwarded as-is), and one `error` handler per image degrades it to alt text, which covers a remote image the page CSP blocks, a 404 for a document outside the workspace, and an SVG that `file-raw` serves as a download. Never widen a route for this. ⚠️ marked percent-encodes destinations (`my image.png` arrives as `my%20image.png`, CJK names as `%E5…`), so the ref is `decodeURIComponent`ed (a malformed escape is kept as written) and stripped of `#fragment` and `?query` BEFORE the route encodes it again; without that `file-raw` looks for a file literally named `my%20image.png`. Workspace links become `a.rv-path` with `data-path` AND `data-session-id` (the preview's session, which the `app.js` delegate prefers over `activeSessionId`, since a preview opened from another session's attachment card must resolve links against that workspace) and lose the `target`/`rel` that `_renderMarkdown` gives every link, which would otherwise open `<origin>/docs/x.md` in a new tab; fragment, protocol-relative and http(s) links are untouched. The absolute paths `_linkifyFilePaths()` then finds in the prose get the same `data-session-id`, set on every `a.rv-path` still lacking one. ⚠️ **An attachment opened under a bare file name has no directory.** Attachment cards and the history drawer call `openFilePreview(relativePath || fileName, sessionId, attachmentId)`, and a registered attachment's `relativePath` is always `''` (`attachmentRecordToEvent`), so `filePath` is just `report.md`: resolving against it sent `docs/report.md`'s `img/chart.png` to the workspace root's `img/chart.png` (a missing image) and its `CONTRIBUTING.md` link to the root's (a silently different file), and an out-of-workspace attachment's refs all landed in the workspace. `filePreviewText` therefore carries `attachmentId`, and when it is set with a non-absolute `filePath` the rebase degrades every workspace ref instead: images become their alt text (as a text node), links are unwrapped to their text. An absolute-path attachment (one `_registerExternalPreview` minted for a clicked path) still resolves against its own directory, as does every workspace preview. - ⚠️ **`data-i18n-skip` on the container.** The translator's MutationObserver translates inserted headings and paragraphs, and the `.file-preview-content` entry in its skip list matches nothing (no element has that class), so the attribute is what keeps a Chinese UI from rewriting a README. - ⚠️ **Toggles are per-device, in their own localStorage keys** (`codeman:filePreviewMdRendered` / `LineNumbers` / `Wrap`), for the same reason as the Files panel's show-hidden toggle: the app-settings object is rebuilt from the settings modal on every save, and they are display state, not synced settings (`SettingsUpdateSchema` is `.strict()`). MD re-renders from the kept source (`filePreviewContent`, which is also what Copy copies) without a refetch; Lines/Wrap are class flips on the one `<pre>`, whose rules are scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's own code blocks. Lines are one inline `<span class="fp-line">` per line joined by real newlines, the counter in `::before` with `user-select: none`, so select and copy return the exact text. - ⚠️ **Caps.** Markdown fetches `lines=10000` (the route's `MAX_LINES_LIMIT`), because a rendered document cut at 500 lines reads as the whole document; other text keeps 500, which is what stops a huge log locking the tab in one `<pre>`. The attachment (out-of-workspace) branch keeps its 512 KB Range read and skips the 500-line clip for markdown. Edit mode is unchanged and still re-fetches `edit=1`; the three toggles hide while editing and for images, media and PDFs. - ⚠️ **`md` and `markdown` ARE in `FILE_PREVIEW_EXTENSIONS`** (owner decision 2026-10-13). Until then an in-workspace `.md` path clicked in the terminal opened the tail viewer, which showed "streaming" over raw text instead of the document (the live follow it gave Ralph's `fix_plan.md` was not worth that). Now every surface reaches `openFilePreview()` and renders it: the terminal (the main pane, grid tiles and the split's Pane B share `registerFilePathLinkProvider`, and a touch tap activates the same provider), the Files panel, a path clicked in the Response Viewer (its delegate calls `openFilePreview()` directly), and attachment cards. `FILE_PATH_LINK_PATTERN` links `.markdown` (and `.htm`) as well, or such a path would not be clickable at all. Other in-workspace text (`.log`, `.txt`, `.json`, code) keeps the tail viewer. `avif` and `ico` were added to the set (a printed `favicon.ico` used to tail binary noise), with `avif` also in file-content's image set and file-raw's MIME map; out-of-workspace avif/ico stay unregistrable, like svg/bmp. Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above), `test/routes/file-routes.test.ts` (avif). ### Clone a repository as a case **Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` is the folder the repository is cloned INTO, exactly as given (the API adds no case-name subfolder; the **Clone into a custom folder** field on the page is a parent, so the tab sends `<folder>/<case name>`, see UI below), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder. - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. - ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. - ⚠️ **Bounded in time, output and concurrency.** Timeout → SIGTERM → SIGKILL, signalled to the whole process GROUP (`detached: true`, negative pid) because `git clone` fans out into `git-remote-https`/`index-pack` children that a polite signal to the parent leaves running. stderr is kept as a bounded, credential-redacted, control-stripped TAIL; `ls-remote` stdout is capped and refs are capped at 500 each. A small global pool (default 2, `CODEMAN_MAX_GIT_OPERATIONS`) caps concurrent git network ops, same reasoning as `document-conversion-limiter.ts`. - ⚠️ **A direct clone's cleanup has a premise.** One direct (`intoEmptyDir`) clone per folder runs at a time, and a failed one empties the folder only when git actually ran (`GitRun.started`: a full queue or a slot wait that timed out wrote nothing) and did not refuse the folder. That refusal is read from git's own `already exists and is not an empty directory` text, tested directly and never through the classifier's code: the message quotes the path, and a folder named `openssl` once classified as unreachable. A folder whose mount stopped answering is left alone too (`probePathKind()`), so no libuv worker is held on it. - ⚠️ **No clone at or inside a Docker case workspace, with or without a `path`.** Git would run the new repository's hooks and filters on the host. Refused `400` on both branches. - **Repository contents beat scaffolding.** An existing `CLAUDE.md` is kept (a generated one is written only when absent) and hooks are MERGED into whatever `.claude/settings.local.json` the repo shipped. A repo that ships its own `.claude/settings*.json` is reported back as a warning, because repo-supplied hooks run on the user's machine as soon as a session starts there. - **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree. - ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated. - **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary. - **UI** (`case-clone` tab in the Add Case modal): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `<folder>/<case name>` as `path` (`_cloneTargetPath()`), so the API's `path` stays the exact target; debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`). Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint), `test/clone-custom-folder-ui.test.ts` (the tab's parent folder, the request and the preview, against the real markup). Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`. ### Git worktrees of a case **Git worktrees** (case picker → **Git worktrees…**, the phone case sheet's **Worktrees**, Close Session's **Kill & remove worktree**): a LOCAL case whose folder is the top of a git repository keeps worktrees at `<repo>/.claude/worktrees/<name>` on a new branch (default `worktree-<name>`), so parallel agents each get their own checkout instead of sharing the case folder. That is Claude Code's own `--worktree` layout (measured in 2.1.296: same path, branch `worktree-<name>`, names of `[A-Za-z0-9._-]`, at most 64), and Codeman's names are a strict subset of Claude's. Core in `src/git-worktrees.ts` (pure name/branch gates, `worktree list --porcelain` parsing, exclude text; IO behind an injectable runner); routes in `routes/worktree-routes.ts` (`GET`/`POST /api/cases/:name/worktrees`, `DELETE /api/cases/:name/worktrees/:worktree`). - ⚠️ **Only `<repo>/.claude/worktrees/<valid-name>` is ever created or removed.** Every component is `lstat`ed (a symlinked `.claude`, `worktrees` or worktree folder is refused, never followed) and the real path must equal the lexical one, so a repository cannot redirect a create or remove outside itself; removal also requires git to list the folder as a worktree of THIS repository. A worktree anywhere else is listed read-only (`managed: false`). A managed worktree whose folder is GONE stays removable (`resolveManagedWorktree({ allowMissing })`, removal only, never a session start: git lists it at exactly the managed path, no component on the way is a symlink, and `git worktree remove` then only drops git's record), since otherwise its name could never be used again. - ⚠️ **git never runs for a case at or inside a Docker case workspace** (or whose common git dir is there), and Docker and remote cases are refused with a 400 before any git, the same rule as the git status indicator: `git worktree add` runs the repository's post-checkout hook and checkout filters ON THE HOST. That is acceptable for a local case, where the user's agent already runs code. A managed worktree that is, sits inside, or holds a Docker case workspace (a Docker case linked to the worktree folder) is never removed (400): `git worktree remove` runs a status inside the tree and then deletes it, ignored files included, under the container. - ⚠️ **Removal is `git worktree remove` WITHOUT `--force`**, refused (409) while any live session (any owner) runs at or inside the worktree, and git's own refusal (modified or untracked files, a lock) is the answer, shown through `textContent`. The optional branch delete is `git branch -d`, never `-D`: an unmerged branch survives and the response says why (`branchError`). - ⚠️ **The main checkout must not see the worktrees.** When `git check-ignore` says they are not ignored, `/.claude/worktrees/` is appended to the repository's LOCAL `<common git dir>/info/exclude` (never a tracked `.gitignore`), and so is `/.claude/settings.local.json`: Codeman writes its hooks there for every claude session, and an untracked copy in a worktree would make `git worktree remove` refuse forever (Claude Code covers that file with the global excludes file, which a machine may not have). The agent skill copy (`<skills dir>/codeman/`, injected while `agentSkillEnabled` is on) gets a line too, for every skills folder the registry names (`capabilities.agentSkillDir`, passed in by the route as data). A symlinked `info` dir or `exclude` file is reported, never written through. - **Names are gated before git**: charset and length (Zod and `worktreeNameError()`), then `git check-ref-format --branch`, whose output must EQUAL the input (`--branch` expands `@{-1}`, which the charset gate already refuses). The base is a resolved commit (`rev-parse --verify <base>^{commit}`), passed after `--` with `--no-track`; an existing branch is refused (`-b`, never `-B`, which would reset it). Create and remove are serialized per repository. - **Sessions start through the EXISTING create paths**, never a new spawn path: `POST /api/quick-start` with `worktree` (resolved with the same guards; hooks, the agent skill, the model override and the session all target the worktree, and a case that does not exist is a 404, never a scaffold), or `POST /api/sessions` with the worktree as `workingDir` (what the Run button uses for claude and shell). The frontend launches through `run({ worktree })` with a temporary `_runMode` swap, tabs named `w<n>-<worktree>`. - The unified session list names a live session's worktree from its folder layout (`worktreeFromPath`), so a codex or shell session gets the worktree pill before (or without) a transcript record. Tests: `test/git-worktrees.test.ts` (pure half, then REAL git: create, exclude, symlink guards, dirty/live/locked refusals, `branch -d`), `test/routes/worktree-routes.test.ts` (real repository through the routes and quick-start, Docker/remote/ownership refusals), `test/worktree-ui.test.ts`, `test/worktree-modal-i18n.test.ts`. ### Ultracode and workflow-run visualization **Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. ### 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.** `-d` and `service install` both check the pidfile AND probe `/api/status` first, and refuse. This is the instance-isolation hazard, not politeness: a second instance on the shared `tmux -L codeman` socket discovers the first one's live sessions, attaches PTYs to them and resizes them (see [Instance isolation](#instance-isolation-and-the-multi-instance-attach-danger)). - **Never report success that was not observed.** The parent polls `/api/status` until the child answers or exits, then prints the URL or the tail of `web.log`. A `launchctl load`, a `systemctl enable --now` and a plain spawn are all silent about a server that starts and dies half a second later, which is why `install.sh` verifies 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_PASSWORD` gates `/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. - **`--stop` verifies identity before signalling.** Pids are recycled; a stale pidfile plus a blind `process.kill` is how a tool SIGTERMs someone's database. `ps -o command=` (portable to macOS) must still look like a Codeman web process. When `ps` itself fails, the pid is treated as ours rather than orphaning the pidfile. - **One source of truth for the job name.** `src/config/service-names.ts` holds the systemd unit name and launchd label used by `install.sh`, `detectSupervisor()` in self-update, and `service install`. Drift here is silent and bad: `service install` would 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:/sbin` and systemd's user manager is nearly as bare, so a Homebrew or nvm `node`, `tmux` or `claude` is simply absent. The unit therefore carries the installing shell's PATH with the running node's directory in front; `node_modules/.bin` entries are dropped, since npx injects those for one command and they would outlive the checkout. - **No secrets in unit files.** `CODEMAN_PASSWORD` present in the installing shell is NOT copied into the plist/unit; the operator is told to add it. `CODEMAN_INSTANCE` IS 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: 1. **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. The `Referer`-keyed 404 fallback is only a rescue (it fires after every real route missed, and only when the browser sends a usable `Referer`), so the fix is `runtimeUrlShim()`: 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 as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` 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 wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it. 2. **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 carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with 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. `curl` cannot 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 the response's `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); relay the REQUEST's `content-length` whenever a body goes upstream (`relayBodyLength`, no `transfer-encoding` present), see Fastify specifics; 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). ⚠️ **Bytes, and the FRAMING too**: undici sends a stream body with no known length as `Transfer-Encoding: chunked`, and a server that reads only `Content-Length` (Python's `http.server`, many small Go/C ones) then sees an EMPTY body. That broke every POST to such a dashboard until 2026-10-21 (a WoL page's Shut down lost its `{"confirm":true}` and was refused on every click, while curl straight at it worked), so `buildUpstreamRequestHeaders` puts the browser's `content-length` back, pinned end to end by `test/webview-proxy-body-framing.test.ts` against a length-only upstream. 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) and `~/.local/bin` (where Settings installs CLIs, on the home bind mount so they survive a rebuild) are APPENDED to `PATH`, never prepended, and the entrypoint pins its own `PATH` to the system dirs: the root part of the start resolves `setpriv` by bare name, and a prefix ahead of `/usr/bin` let a planted `setpriv` run as uid 0 (measured). `~/.local/bin` is writable from the host as well, and root `docker exec` and the healthcheck inherit the image `PATH` without the entrypoint's pin; `test/docker-entrypoint.test.ts` requires every `ENV PATH=` line to start `$PATH:`. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. ⚠️ **`docker/.env` is generated FROM `.env.example`, at 0600, never from a list of keys.** `Start-Codeman.sh`'s first run (only when nothing exists at that path: a dangling symlink or a directory is refused) rewrites the five `KEY=` lines it asks about and copies every other line, because `diffRequiredEnvKeys` refuses an in-app update while the user's file lacks a key the target release's example sets, so a hand-picked subset would block the very next update. Compose's dotenv is not bash: a backslash escapes a single-quoted value's closing quote (measured on 2.33.0 and 5.5.0), so `accept_password` and `accept_appdata_path` refuse a trailing backslash as well as a single quote. Compose also prefers the shell environment over the file, so setup unsets what it wrote, or a preset would beat a typed answer. `test/docker-start-setup.test.ts` pins all of it, against a stub that reproduces both rules. ⚠️ **`changeme` is refused by BOTH scripts**, before anything is built or stopped: the container publishes on every interface and holds the Docker socket, so that password is host root. `Update-Codeman.sh` needs its own check, ahead of its `down`, because a refusal found only at the handoff to `Start-Codeman.sh` would leave the stack stopped. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. User guides: `docs/docker-compose.md` + `docker/README.md`. ### DeepSeek web UI **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/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the create and respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id and never onto the stored `<Mode>Config` (that config is what a clear falls back to; the quick-start route used to write the launch model there, so a clear relaunched the endpoint's id on the native backend), and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. **Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (`wire_api = "responses"`), re-verified live against a llama-swap deployment that DOES answer `/v1/responses` (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as `agent_message` TEXT (the tool-call JSON printed as the answer) rather than an executable `function_call` item, confirmed via `codex exec --json`'s raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set, unresolved after real investigation); deepseek's originally-reported `HTTP_404` is root-caused and fixed, its bundled `@deepseek-ai/dsh-llm-deepseek` module builds `${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` of its own (confirmed by installing the real package and reading its source), live against llama-swap (`.../chat/completions` 404s, `.../v1/chat/completions` succeeds), and the env-var redirect that first fixed it has since been REPLACED by a `configDir` recipe: a per-session `DSH_HOME` carrying only a generated `cordis.patch.yml` that declares a `codeman-custom` `openai-completions` provider over the installed profile, with the `/v1` fix living in `renderConfigFile`'s shared `withV1Suffix()` (every file template applies it; the `env` kind's `appendV1Suffix` flag now belongs to copilot alone) and the session's state symlinked back from the real dsh home; a real "hello world" reply through an actual `dsh` binary has since been verified live by the maintainer (dsh 0.1.1-rc.2, dsh-TUI 0.10.0-beta.1: `--dump-config` shows the patched rows, the TUI boots on the chosen model, and a prompt reaches the endpoint as `POST /v1/chat/completions` with the reply rendering in the TUI), so this is verified end-to-end like the harnesses above; antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `</script>` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). ⚠️ **The picker promotes exactly one row to the top**: whichever model llama-swap reports `ready` right now (tagged "Currently loaded", queried via `GET /api/model-endpoints/:id/running-status`, client-side bounded to ~800ms via `Promise.race` so a sleeping/firewalled endpoint cannot leave the modal invisible for the route's own 5s server-side timeout) beats a live session's pin (tagged "In use by a session": `_getCustomModelInUseBySession` walks `this.sessions` for one of your own sessions whose `customModel.endpointId` is that host, newest `createdAt` wins), which applies only when nothing is currently loaded and beats a merely remembered choice, the model actually launched last for this exact (harness, endpoint) pair (tagged "Last used", read from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>` localStorage key). ⚠️ The pin reads only `customModel`, never `displayModel` (screen-scraped display text: a terminal that merely printed a matching string is not evidence), and never counts a session with `status: 'stopped'`, a null `pid` (its PTY exited) or a recorded `paneExit`, the same live-pane test keep-warm applies. ⚠️ When nothing was promoted and the probe answered `isLlamaSwap: false` (the shape `getLlamaSwapStatus` gives for EVERY failure it catches, a down or refused endpoint included, so the line claims only that nothing was reported), the static `#customModelPickUnknown` line says so out loud; its `[hidden]` rule must keep `display: none !important` scoped to `#customModelPickModal`, because `.form-hint`'s `display: block` outranks the UA `[hidden]` rule. Neither tag reorders past the top, and the "Default" pill is a SEPARATE span rather than a third value of the same slot, so a promoted row that is also the endpoint's `defaultModelId` shows both (on a single-purpose GPU box that is the common case; an exclusive slot silently dropped the Default marking for exactly that row). "Last used" is written by `_runCustomModelEntryViaRestart` (claude) and `_quickStartWithCustomModelConfirm` (every one-shot launch; `runCustomModelEntry` itself only dispatches between the two) only once the model is actually applied, never on the mere click, because a context-window-warning decline means this exact model cannot work with this CLI at all, and promoting a model that cannot launch would be actively wrong, not just premature. Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs `/api/sessions/:id/wait?until=idle&timeout=20000` on that session BEFORE applying — measured live, a freshly launched CLI reports itself `busy` for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's `isBusy()` guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with `SESSION_BUSY` until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open. ⚠️ 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. The same rule covers the slot check: `/props` supplies `total_slots` only when `cmd` has no `--parallel`, and it NEVER widens the pool the slots divide (an earlier cut let a larger `/props` `n_ctx` become the pool and skip the warning; the 154112-vs-16384 measurement is exactly why that is wrong). `/props` refreshes llama-swap's last-use time for the model, so `discoverModels` probes it once per launch command (`propsByCmd`, in memory, keyed by host and model id and compared with `/running`'s `cmd`); probing on every 5-minute sweep would keep a model with a `ttl` loaded forever. With an explicit `--parallel N` llama.cpp reports `n_ctx` per slot, so a `/props` context beside it is stored with a slot count of 1: it is not divided a second time, and the 1 overwrites a count an earlier discovery round stored (`applyDiscoveredModels` merges onto what was learned before, so recording no count would keep the stale one). ⚠️ **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, or when `contextLength / slotCount` (`perSlotContext()`, from `modelSlotCounts`; an unknown count is one slot) is below it, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` (plus `slotCount` when it is above 1) 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. ### Local model install **A model that runs on this Mac** (`src/local-model/`, `src/config/local-model-catalog.ts`, routes `src/web/routes/local-model-routes.ts`; EXPERIMENTAL; guide `docs/local-models.md`): the server looks at the Mac it runs on, says which local coding model fits it, and installs one: a pinned runtime it supervises, the model, the checks, the coding agent, and one saved model endpoint. The read side (`hardware.ts`, the catalogue, the pure `recommend()`) is described in the root `CLAUDE.md`; this section is the install. **The layout** is one pure function (`localModelPaths()`, `paths.ts`): `<data dir>/local-model/` holds `runtime/ollama-<version>/` (plus its `.tgz.partial` and `.unpack` while it is fetched), `models/` and `home/`; `<data dir>/local-model.json` is the state file. ⚠️ Every path comes from `dataPath()` through that function and NONE is ever read back from the state file (which holds no path), so a state file cannot point a delete anywhere. ⚠️ Nothing is written or deleted outside `local-model/` except the state file and the two writes of the connect step. The runtime's `HOME` is the private `home/` (measured to work), so its key pair lands there and the user's own `~/.ollama` is never created, read or removed; `home/` and a `.ollama/id_ed25519` anywhere are on the sensitive-path list. **The pin** (`OLLAMA_RUNTIME_PIN`, `runtime.ts`) is a COMPILED constant: version, URL, size, sha256. ⚠️ No file, setting, env var or request may supply any part of it: a hash that comes from the same writable place as the URL pins nothing. The archive is streamed to `*.partial` while it is hashed, continued with a Range request (3 bounded retries), refused when it grows past the pinned size, and the hash is compared BEFORE `/usr/bin/tar` unpacks anything into a temporary folder that is then renamed. **Supervision** (`createRuntimeSupervisor()`): the runtime is a NON-detached child, started at boot once a model is installed and enabled, restarted at most 3 times in a row, SIGTERMed at shutdown beside `stopDeepSeekWeb()`. ⚠️ Its environment comes from the pure `runtimeEnv()` ALLOWLIST (PATH, the private HOME, TMPDIR, LANG, the proxy variables, and exactly `OLLAMA_HOST=127.0.0.1:<port>`, `OLLAMA_MODELS`, `OLLAMA_CONTEXT_LENGTH`, `OLLAMA_NO_CLOUD=1`, `OLLAMA_KEEP_ALIVE=10m`, `OLLAMA_NUM_PARALLEL=1`, `OLLAMA_MAX_LOADED_MODELS=1`, plus the llama.cpp memory settings `OLLAMA_KV_CACHE_TYPE=q8_0`, `OLLAMA_FLASH_ATTENTION=1`, `LLAMA_ARG_CACHE_RAM=1024`, `LLAMA_ARG_CTX_CHECKPOINTS=8`, which the catalogue's `llamacpp` rows are computed for, so the two change together): never `process.env` whole (it carries `CODEMAN_PASSWORD`, and a user's own `OLLAMA_ORIGINS=*` would open the port to every web page). ⚠️ The port is chosen ONCE by binding (11435 across 20; 11434 belongs to a runtime the user may run) and stored; it is never searched again on a later start, because it is part of the saved endpoint's URL. ⚠️ Pid and port are in the state file: a listener on the port that answers `/api/version` and whose pid runs a binary under OUR runtime folder is our own orphan (a server that was killed leaves one: a child is not ended by its parent's death) and is adopted or replaced; ANY other listener is `port-in-use`, never something to talk to. ⚠️ The supervisor never writes the state file: it reports through `onChange`, and the job stores what it reports. ⚠️ Under vitest nothing spawns and nothing is fetched unless a test injects it. **The budget is measured.** The runtime prints the device total in its log line "inference compute" (`total="37.4 GiB"`); `parseInferenceComputeTotal()` reads it, the job stores it and judges the chosen model against it (`modelFitsBudget()`) BEFORE the model is downloaded; from then on the GET recommends with `budgetSource: 'measured'`. One consent covers the whole install: the sizes that were confirmed do not change, so a model that no longer fits is a failure with the next model, not a second question. **The job** (`install-job.ts`) is a pure reducer (`reduceInstallJob()`) over three tables in that file (`INSTALL_STATE_TABLE`, `INSTALL_STEP_TABLE`, `INSTALL_TRANSITIONS`), driven by one service per process (`getLocalModelService()`). States: `idle`, `installing`, `paused`, `failed`, `installed`; steps in order: `runtime`, `model`, `verify`, `agent`, `connect` (`agent` is left out when the coding agent is installed). ⚠️ An event the transition table does not list is REFUSED by returning the same object, which the service answers with 409 `wrong-state`; add a transition to the table, never a special case to the service. ⚠️ ONE job at a time: verbs run on one queue, so two installs sent together cannot both find the state free. ⚠️ A job the state file still calls `installing` when the process has none was interrupted by a restart: it becomes `paused` the first time anything looks (`boot()`, a GET or a verb), and NOTHING continues without a click. `shutdown()` therefore writes nothing: it aborts the step and leaves `installing` in the file. ⚠️ An interrupted step is never recorded as failed, whatever its last call answered (an aborted fetch looks like a network error): the job checks its own signal before it writes `failed`. ⚠️ Every step is idempotent, because Resume and "Try again" re-enter the step the job stopped in. ⚠️ Cancel in the `connect` step is honoured only before the first write: once the two writes began they finish, so the pair is never left half done. ⚠️ The state file (`state-store.ts`) has ONE writer, serialized, temp file + rename, mode 0600, and reading is TOTAL (a file that does not parse, another schema, or a state that lacks a member it needs reads as idle): it holds nothing of the user, so it is replaced rather than refused, unlike `settings.json`. ⚠️ Progress is kept in memory and written at most every few seconds and when the job pauses or fails; the window reads the live figure. A 23 GB pull emits about 1.4 million lines, so neither the stream nor one state write per line is affordable. **Install is checked before anything is downloaded** (`LocalModelService.install()`): the model, the context and the coding agent must be a row the CURRENT recommendation offers (a model id is looked up, never passed on: without a slash it cannot name another registry host, and the request schema repeats that); the coding agent must be a harness row of the catalogue; `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`: the model plus the runtime archive while the runtime is not unpacked), else 409 `size-changed` with that total, so a consent is bound to the size that was shown; and a saved endpoint that already has the reserved id and is not ours is said now, not after a 23 GB download. **The check after the pull** (`verifyModel()`): one tiny chat with `reasoning_effort: "none"`, then `/api/ps` must show `size_vram == size` at the chosen context, then one tool probe (`tools`, `reasoning_effort: "none"`, `max_tokens` 1024) that passes on a tool call with parseable arguments. ⚠️ Thinking is switched off and the cap is generous on purpose: every catalogue model thinks first, and a probe that ends on its token limit proves nothing (`probe-unfinished`, never `no-tool-call`). ⚠️ A failure is a state with a code and ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed". ⚠️ The job fails when the runtime reports no digest for the model: there would be nothing to compare later. **The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP. **The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId, defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest. **The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment. **Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove. **Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`. **The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it. Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`. ### 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), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal ALONE is restricted to `idle` items; a permission/question item gets the pane-VERIFIED variant on that same signal (`resolveIfDialogGone()` → `verifyStillAnswerable()`), so the heuristic only decides when to LOOK and the screen decides the outcome. That is what clears a dialog answered in the terminal mid-turn; the other definitive signals are `stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede and the 12h TTL. ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the four selections the APP makes (boot restore, a solo window opening its target, a `#session=<id>` link from another page, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). ⚠️ **`applyCapture()` is therefore ADD-ONLY for `options`**: a re-capture that parses nothing must never erase a parse an earlier one found. Claude Code delays the Notification hook behind the dialog (measured 6s, documented ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered; clearing the field there made the item permanently unsweepable, because `verifyStillAnswerable()` reads a MISSING `options` as "we never could read this dialog" and keeps such items answerable by design. The red "needs you" then survived every sweep AND every page reload, went away only on `stop` (2026-08-20: a confirmed question left a tab flowing red for ~8 minutes while the turn ran on), and the stale card still accepted an answer, typing a bare `1` into a composer with no dialog under it. Pinned by `test/approval-inbox.test.ts`. ⚠️ A frame that parses no options is CONCLUSIVE in exactly two cases, and the second one closes the late-hook hole: the item once parsed options (they cannot vanish while the dialog is up), or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist — measured on v2.1.237, a live-dialog frame carries neither the `… (13s` timer NOR the `esc to interrupt` footer, which the dialog replaces with `Enter to select · ↑/↓ to navigate · Esc to cancel`. Anything else stays answerable, so an unreadable capture still keeps the alert. That second signal is reached by a delayed staleness pass (`STALE_CHECK_DELAY_MS`, 3s) scheduled alongside the re-capture, because a prompt answered BEFORE the hook lands creates an item whose FIRST capture already has no dialog in it: nothing ever parsed, `stop` may have fired already, and the alert then outlived reloads until the 12h TTL. ⚠️ That pass must stay comfortably LATER than `RECAPTURE_DELAY_MS`, whose whole reason for existing is that the hook can beat Ink to the screen — resolving inside the paint window would clear the alert for a dialog that was about to appear. The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. ### Read My Mind intent profiles **Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`. ### Agent inbox **Agent inbox** (discussion #558): a per-session mailbox one agent posts to and another reads, so a coordinator can hand a worker text without typing into its pane. Store `web/agent-inbox.ts` (pure, `sessionWaits`-style singleton, no IO), routes `routes/inbox-routes.ts`, CLI verbs `codeman agent post|inbox|ack`, opt-in snapshot `web/agent-inbox-persistence.ts`. Nothing in it writes to a pane, and it is kept out of `toState()` so a post does not fan out a `session:updated` to every browser; the counts live behind `GET /api/agent-inbox/summary` (owner-scoped, counts only, never text), which `codeman agent ls` merges into its INBOX column. ⚠️ **Read until ack.** A read removes nothing; the same messages come back until the receiver acks them BY ID (`POST …/inbox/ack`, idempotent: an id already gone is not an error). Acking by id rather than "everything I read" is what keeps a message posted between the read and the ack from being lost. ⚠️ **Seen and ack belong to the receiver.** The caller is whatever `resolveParentSessionId()` makes of `X-Codeman-Parent-Session` (live session, same owner as the receiver), never a body field (`InboxPostSchema` is strict). Only a read whose caller IS the receiver marks messages seen; anyone else's read is a peek. An ack from anyone else is a 403, not a silent no-op, so a coordinator never believes it cleared a worker's mail. In multi-user mode this stops a non-admin caller from naming another owner's session (an admin passes `canAccessOwned` for every session, so an admin can act as any session of the receiver's owner); in single-user mode every local process holds the same credentials, so the header is the client's own claim about itself, which is all the seen/peek split needs. ⚠️ **`?wait=` parks while nothing is UNSEEN**, not while nothing is pending: a receiver holding a seen-but-unacked message would otherwise get an immediate answer on every loop and spin. Inbox waiters are the registry's third waiter kind (`waitForInbox`/`notifyInbox`), so they count against the same per-session/owner/total caps and are ended by `cancelAll` like signal and output waiters. `notifyInbox` runs AFTER the store, so a woken reader finds the message. `parked` in the summary counts receiver waiters only. ⚠️ **Caps refuse, they never drop.** 16 KiB per message (UTF-8 bytes), 512 KiB and 200 messages per inbox; a full inbox is 409 CONFLICT and nothing is stored. Dropping the oldest would lose exactly the message a slow receiver has not handled yet. TTL is 12 h from each message's own `createdAt`, swept lazily, and a restore keeps `createdAt`, so a restart does not restart the clock. ⚠️ **Lifetime follows the session, not the pane.** `_doCleanupSession` drops the inbox only when `killMux` is set (a real delete); a detach or server shutdown keeps it, and `DELETE /api/sessions/:id` on a row that is no longer live (the persisted-only branch in `session-routes.ts`) drops what the detach kept. A detach also ends a parked read with `ended: true`, which is why `codeman agent inbox --wait` asks the session list before it exits 3. Persistence (`agentInboxPersistEnabled`, SYNCED, default OFF) writes `agent-inbox.json` via `dataPath()`, 0600 through tmp+rename, debounced and flushed on stop; off means no file, and switching it off deletes the snapshot while memory stays. It is never fed to `/api/search`, and `sensitive-path.ts` keeps the file viewer off it. Boot loads the snapshot before `restoreMuxSessions`, but prunes inboxes of sessions that no longer exist only in `finalizeRestoredState`'s SUCCESS path: a failed restore must not delete mail for sessions that are about to come back. The keep set is the live sessions, the records `state.json` still holds AND the sessions on the reboot-restore offer (`rebootRestoreRegistry`): `cleanupStaleSessions()` has already pruned an offered session's record by then, and a rebuild reuses its id, so leaving the offer out returned every rebuilt session with an empty inbox (the 12 h TTL bounds what is never rebuilt). `POST /api/cleanup-state` drops the inboxes of the records it removes, since it is a delete for a detached row, except a record whose tmux session the multiplexer still lists (`mux.getSession`): a detach keeps that pane, and the next boot re-adopts it under the same id even without a `state.json` record. Tests: `test/agent-inbox.test.ts`, `test/agent-inbox-persistence.test.ts`, `test/routes/inbox-routes.test.ts`, `test/agent-inbox-server-wiring.test.ts`, the inbox block in `test/session-wait-registry.test.ts`. ### 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**. ⚠️ Claude transcript frames are cumulative within the current utterance: replace interims, then append each endpoint final once. An endpoint does not stop dictation unless finalization was requested. Finalization has a 5-second client backstop; reconnect cleanup salvages accumulated/interim text and releases the microphone immediately. 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. ⚠️ **Both walks are a per-directory round-robin (breadth-first) over ONE 5,000-node budget.** They used to recurse depth-first through name-sorted folders, so an early folder could spend the whole budget: 4 of 57 top-level folders were shown in one real workspace. Each round divides what is left equally among the directories queued so far, and a folder's own files count as one more share beside the subfolders it has just found (`walkTurnEnd()`). Those subfolders are read in a later round, so a folder that spent all of its share on its own files left them nothing, alone in its round or beside a sibling, and search hits inside them were lost. A turn that found no subfolder holds nothing back. That is fair per directory, not per subtree: a folder of thousands of one-file subfolders still crowds out its neighbours one level down (`a/b/c/d/needle.ts` beside such a `z/` is found depth-first and missed here), and that trade is accepted, since a file browser wants the shallow levels first. Search results therefore arrive shallow-first. ⚠️ **The budget caps what a listing returns, never what it reads.** Taking turns means opening every folder found so far, where the depth-first walk stopped reading soon after its budget ran out, and a share of 16 entries costs the same read as a share of 5,000. So `readWalkFolder()` keeps only what the listing can use (the entries it lists, sorted, no more than the budget has left, with `more` recording that it dropped some: such a cursor stays queued and is flagged), and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders. The folder that crosses that line is read whole, as any folder always was, the ones not opened yet wait in `unread` and are flagged, and what was already read keeps its turns. Without the two, 100 folders of 50,000 files cost one GET 5 million entries and a 400 MB heap died (which ends the server, not the request). The price is that folders adding up to the ceiling leave later ones unread: flagged, not hidden, but not the fairness the budget gets, and a depth-first walk over the same tree showed less. ⚠️ **A folder below the walk's root is probed before it is read** (rule #516), because the round-robin reaches folders the depth-first walk stopped short of, each with an unbounded `readdir`. It is a bulk scan, so default options and the stall cap apply. `unknown` is never `absent`, so a folder the probe could not answer for goes on `unread` (`cursor.unanswered`), which flags it `incomplete` and sets `truncated`: a healthy mount that merely took longer than `PATH_PROBE_TIMEOUT_MS` on first touch would otherwise list as empty, and a search would miss it in silence. It stays unread until a later listing asks again. Only `absent` or `file`, a folder that left while the walk ran, drops out with no flag. With the cap engaged (two unrelated paths stalled, by default) every folder below the root is flagged, until one answers, which is the honest outcome: none of them was read. The root is not probed: a refused probe there would blank a healthy workspace. A `readdir` that fails after a good probe (permission denied) is still treated as an empty folder, as it always was. ⚠️ A directory gets its node, with `children: []`, when it is counted and is read in a later round, so every cursor still queued after the loop, and every one in `unread`, is a folder that is unread or half read: it must carry `incomplete: true` or it reads as an empty folder, the failure that went from 0 to 635 such folders before the flag existed. `renderFileBrowserTree` draws one dim, inert row under it while it is expanded, worded to fit either reason, and the row is not a `.file-tree-item`, so the click wiring leaves it alone. The budget check inside the round runs BEFORE each `shift()`, so a directory the round skips stays queued and keeps its flag. Hidden and excluded entries are dropped at the read, so they never leave a folder looking unfinished, but a folder found as the budget ended is flagged unread even if it would have turned out to be empty: only reading it could tell. The walk's root has no node, so `truncated` is its only signal, and a folder dropped at the depth cap is not flagged: it looks the same as it always has. ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` or `root=live-any` listing; the returned `path`s stay relative to `workingDir`, except in a `root=live-any` listing of a worktree outside it, whose `path`s are absolute), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. ### Files panel in a worktree **Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `<repo>/.claude/worktrees/<name>`, while `Session.workingDir` is the session's identity and stays on the main checkout, so the panel kept listing a tree the agent had left. `paneCwd` does follow, in both directions, because tmux reads the process cwd (measured on 2.1.318). Hook payloads are the wrong signal: their `cwd` follows every `cd` in a Bash call too. When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktree()`, which walks up from the pane and recognises a linked worktree by its `.git` FILE (`gitdir: .../worktrees/<id>`). It never runs git, since this fires from the pane watcher and a file read starts no process, config or filter. The walk stops at `workingDir`, at a repository or submodule of its own and at anything it cannot read; probes are bounded (#516), and a `.git` that is a symlink or a FIFO is refused before anything opens it. The answer is published as `SessionState.liveWorktree`. A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ **That answer is lexical and goes stale.** It is judged only when the pane moves, and a symlink can take the place of the folder, or of a parent, afterwards without moving the pane, while `probePathKind()` and `readdir` both follow one. So `root=live` and `root=live-any` check again on every request: `canWalkLiveWorktree()` (file-routes.ts) probes the folder, resolves it and `workingDir`, and the walk starts at the worktree only when that resolves strictly inside the resolved `workingDir`; otherwise it starts at `workingDir`, as it does for a worktree that has gone. The 1.49.0 release review reproduced the escape end to end on tmux 3.4: without this check a symlink swapped in after the lookup listed the names and sizes of a folder outside the workspace, though `file-content` and `file-raw` refused those paths. The root is the only place this can happen, because the walk does not follow a symlink below it (`Dirent.isDirectory()` is false for one). A swap between the check and the `readdir` is the same check-then-use race any path route accepts. ⚠️ **Only the check uses resolved paths.** The walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`, and the listing's `path`s stop working on the file routes. ⚠️ A `liveWorktree` inside `workingDir` only moves where a listing STARTS, so never make it a confinement root. Returned `path`s stay relative to `workingDir`, which is why `file-content`, `file-raw` and the edit route need nothing new for it. **A worktree outside the session's folder** (`git worktree add ../feature`, or an EnterWorktree that the user's own tooling places beside the repository) is the one case that IS a second confinement root, and it is published as `SessionState.outsideWorktree` (carrying `outside: true`), never in `liveWorktree`, which stays inside-only on the wire. `refreshLiveWorktree()` passes `findLiveWorktree()` the outside rules (`outsideWorktreeOptions()`, server.ts; none for a remote or Docker session), and `findOutsideWorktree()` answers only when all of these hold: - The session's `workingDir` is the top of its repository or of a linked worktree (its `.git` sits there), so its git common dir is known. A session started in a subfolder follows no worktree outside it. - The nearest `.git` at or above the pane is a FILE naming `<common dir>/worktrees/<id>`, and that registry entry's own `gitdir` file names the candidate back. Or it is a `.git` FOLDER that is the common dir itself, which is the main worktree seen from a session started in a linked one. These are the files `git worktree list` reads; git is never run, because this fires from the pane watcher on every directory change. A forged `.git` file pointing at a real entry fails the back link. - The candidate's real path is not `/`, not the home folder or above it, and does not hold `workingDir`. - It is not at, inside or around a Docker case workspace. An unreadable `docker-cases.json` follows nothing, since it could hide a container's workspace. - Attachment workspace confinement (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) is off. - It is not at, inside or around an attachment blocked tree (`attachmentBlockedPaths` / `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), and not a sensitive folder: the sensitive patterns describe file locations, so `outsideWorktreeOptions()` also probes a child of the root, which refuses a root such as `~/.ssh`. The routes judge both guard rules again on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. The Docker and multi-user rules are judged only when the pane moves. - In multi-user mode, the owner's `folderAllowedForUser` rule admits it (an admin owner is not limited by it). Every comparison uses real paths, except that a home folder or Docker workspace that cannot be resolved is compared as written, and the recorded `path` is the real path. ⚠️ **The routes must re-check it on every request**: `outsideWorktreeRootFor()` (route-helpers.ts) and `canWalkOutsideWorktree()` (file-routes.ts) accept the folder only while `realpath` returns exactly the recorded path, so a symlink swapped in later serves nothing from it. ⚠️ Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it or when `isSensitivePath()` names the file, so `.env`, `.aws/credentials` and `users.json` are never served from it (`validateLocalSessionFilePath()`, used by `file-content`, the edit route, `file-raw`, `file-preview`, `file-thumbnail`, `/api/download` and `html-view`). `html-view` also runs the attachment guard for such a path and mints its capability as outside the workspace, so the serve route's per-request confinement re-check covers it. A relative path still resolves against `workingDir` alone, so `../feature/x` is refused. A symlink inside the worktree into `workingDir` is served, since `workingDir` is served anyway. ⚠️ The browser routes a path inside it to those routes, not to the read-only attachment routes (`_isOutsideWorktreePath()`, panels-ui.js), and the terminal opens it in the preview, because the log viewer's `tail-file` stays confined to `workingDir`. A worktree of a session started below the repository's top is not followed, because Claude Code puts it at the top, outside the session's folder, and that session has no common dir to check it against. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that report with the session's state, and an absent report would reload forever. A listing without `root=live-any` is the exception: it omits an outside worktree. While the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). Two surfaces do not move with a worktree inside `workingDir`. The Git panel keeps working from `workingDir`, so it shows the main checkout's branch while the strip names the worktree. And `POST /api/sessions/:id/html-view` applies its dot-segment rule below `workingDir`, so while the session's folder is the main checkout a page under `.claude/worktrees/<name>` is refused and opens as source (a session started in the worktree renders it). For a worktree outside `workingDir` the Git panel also stays on `workingDir`, while `html-view` serves its pages as described above. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. ### Raw file bodies: streamed and range-aware **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` (so a rendered page's own `<video>`/`<audio>` can seek) 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. ### Edit history panel **Edit history panel** (issue #643; `showEditHistory`, per-device, default OFF): which files a claude session edited, local or remote SSH (main agent and subagents), each edit's diff, and images a Bash call produced, read from the session's transcripts by byte offset, never from git. Parser `src/edit-history-parse.ts` (pure), store `src/edit-history-store.ts`, reader `src/edit-history-local-source.ts` (remote: `remoteLocateClaudeTranscript` / `remoteReadClaudeEditSources` in `src/remote-files.ts`), sync `src/web/edit-history-sync.ts`, routes `src/web/routes/edit-history-routes.ts` (`GET /api/sessions/:id/edits[?sync=1]`, `GET …/edits/:entryId`, `POST …/edits/older`, all through `findSessionOrFail` WITH `req`), panel `edit-history-ui.js`. User guide: `docs/wiki/Working-With-Files.md` § Edit history. ⚠️ **Reads only on request, opt-in all the way down.** `server.ts` builds the `EditHistorySync` and hands it to the routes; its only own calls are `remove` (through `forgetEditHistory`, when the session's persisted record goes), `sweep` (startup) and `flushAll` (shutdown). No hook, no timer, no second watcher. The panel drives every read: `?sync=1` when it opens, and while it is open on that session a debounced (600 ms) re-read after a `transcript:tool_end` of an edit-capable tool (Edit, Write, MultiEdit, NotebookEdit, Bash, Agent, Task, SendUserFile), which the existing `TranscriptWatcher` already broadcasts, or the session's `session:idle` (the end of a turn, via `_onSessionIdle`; it also catches a background subagent that writes after the tool end that started it). Not `transcript:complete`: it fires on a `result` line, which claude's interactive transcript never has. A closed panel reads nothing and keeps no badge. Guarded by the "reads only on request" wiring test in `test/edit-history-sync.test.ts`. ⚠️ **Which transcript** (`resolveClaudeTranscript`). The main transcript is the path the session's `TranscriptWatcher` follows (the hook-reported `transcript_path`), so a session is read from where its claude really writes, whatever its `CLAUDE_CONFIG_DIR`. Without a watcher (server restarted, no hook since), `findClaudeTranscript()` looks for `<claudeSessionId|id>.jsonl` under `claudeProjectsDir(session.claudeConfigDir())`: the session's own override (#255, the same dir the relaunch pin checks), else the server's. A config dir set outside Codeman (a shell profile, a wrapper) is never guessed: until the next hook such a session answers `unavailable` / `no-transcript`, and the panel says so. Subagents are `<main path without .jsonl>/subagents/agent-<id>.jsonl`. A different main file (`/clear`, resume): the previous one, next to it in the same directory, is first read to its end with its subagents (over as many syncs as that takes), then the new one from its end with fresh cursors; the stored entries stay. "Load older" reaches back within the current conversation only, and reads the stored conversation's transcript while the switch is still pending. ⚠️ **Bounded reads.** A sync is at most `MAX_FORWARD_PASSES` (3) reads, each at most `READ_BUDGET_BYTES` (8 MiB) shared by the main transcript and all subagents (main first), plus up to 8 KiB of `meta.json` per subagent seen for the first time; a first read starts that far before the end. A file whose chunk the shared budget cut short is not applied but read in a later pass (the store would take a chunk with no line end for one line longer than the budget). "Load older" reads a full-budget block of up to `MAX_OLDER_SOURCES` (3) transcripts per click; with more, they take turns. A line longer than the budget is skipped, going forward and going back, and the line after (before) it is read whole. A subagent transcript that vanished loses its cursor, not its entries. Parsing happens in the request, line by line; nothing runs on the PTY data path. One sync per session at a time, and requests during a run fold into one more run. At most `MAX_LOADED_STORES` (3) stores stay in memory: past that the least recently used one without a run in flight is written (only if it changed) and dropped, and a later request loads it again after that write. ⚠️ **The store holds secrets.** Every diff an agent wrote ends up in `dataPath('edit-history')/<sessionId>.json`, API keys and `.env` contents included. It is written 0600 via tmp + rename (a fresh file each time, so an existing store never keeps wider bits; a new directory is created 0700) and bounded per session: `MAX_ENTRIES` (500) and `MAX_STORE_BYTES` (4 MiB of diff text, paths and ids), oldest dropped, also on load. Each edit's diff is capped at `MAX_HUNK_BYTES` (64 KiB); the file also holds one cursor per transcript file of the current conversation, at most `MAX_PENDING_BASH` (200) unanswered Bash calls of at most 8,192 characters of command text each (a call with an id, cwd or agent id over 4,096 characters is not kept, and dropped on load) and `MAX_AGENTS` (501) subagent labels of at most 1,024 characters per string. `load()` takes the file as untrusted: one over `MAX_FILE_BYTES` (32 MiB) is not parsed, and the rest is rebuilt from its known fields under the same bounds (unknown fields and non-string diff lines dropped, the per-edit cap applied again, a transcript id with anything but `[A-Za-z0-9._-]` dropped, since the previous transcript's path is built from it). Writes are serialized. The store lives as long as the session's persisted record: `forgetEditHistory` runs when `cleanupSession` with `killMux` removes the record (a pinned session's record is only demoted and keeps its history), on the persisted-only `DELETE /api/sessions/:id`, for every record `cleanupStaleSessions` drops at startup and every one `POST /api/cleanup-state` drops. After that startup cleanup, `sweep` deletes the `edit-history/*.json` files whose session is neither live nor recorded (left behind by a crash between removing the record and deleting the file, or by an older build) and tmp files of earlier runs (named per process start, so a pid used again, a container's pid 1, does not keep one). While `remove()` runs, nothing writes the file again: a sync in flight stops after its current read, and a request meanwhile reads nothing (`unavailable` / `removed`). The routes start a sync in the same tick as their session check, so a request that passed the check before the deletion cannot start one afterwards. Then the session's slot goes, and a session that comes back with the same id (reboot restore re-creates it) starts afresh. A store that is not loaded is deleted without being parsed. The store is blocked in `isSensitivePath` (`/.codeman*/edit-history/`, same reasoning as `intents.json`) and never fed to `/api/search`. ⚠️ **Images only through existing file surfaces.** A Bash image candidate counts only when the file's mtime lies in the call's window (start − 2 s … end + 2 s) and no entry already covers that file version; `SendUserFile` images need no window. The panel shows a thumbnail only inside the workspace, through the confined `file-raw` route. An image outside it gets no thumbnail (each would mint an attachment id per render); a click opens the shared file preview, which registers it once through `POST /api/sessions/:id/attachments` with `notify: false` (its extension allowlist and `isSensitivePath` apply). SVGs get no thumbnail, and neither does any image of a remote session (its `file-raw` streams over ssh, one read per listed row); a click previews it. No route of this feature serves a file, least of all a transcript path. ⚠️ **Transcript paths are agent-printed, so they are probed BOUNDED and scanned in linear time** (#516; #693 review). `probeLocalPaths()` sends each candidate through `probePathKind()` (no `pastCap`: a bulk scan), one at a time, and only a path that answered `'file'` gets `realpath` + `stat`; a path on a mount that stopped answering costs one stalled stat, never a sync, a queued sync or `remove()` that cannot finish (`test/edit-history-probe-stall.test.ts`). A remote session's candidates go through `remoteProbePaths()` instead, one bounded ssh round trip per 40 paths; a failed probe ends the probing for that read, and its edits stay in. `PATH_TOKEN_RE` runs synchronously in the request, over Bash output of up to 30,000 characters: an unquoted token may start only at a token boundary (line start or a token-ending character, `:` excluded so URLs stay ignored), and the `file://` branch stops at the next `file://` (`:` stays allowed for timestamped names). A lookbehind that merely excludes some characters lets a match start at every character of a long token and rescan it, quadratic in the line (2.7 s for 30,000 `#`). Time-budget regression tests in `test/edit-history-parse.test.ts`. Forward reads and "Load older" yield to the event loop between the files they parse (up to 8 MiB each); a store removed meanwhile ends the read before its images are probed. Docker and non-claude sessions never load a store on any route. Paths, agent descriptions and diff lines render via `textContent` only (browser test with a hostile path). The header button uses the marker-class pattern (`btn-edit-history--hidden`) and stays off phones (`mobile.css`, locked in `test/mobile-header-buttons-policy.test.ts`). Docker, non-claude and adopted sessions answer `unsupported` and are never read. ⚠️ **Remote (SSH) sessions: read over ssh, never woken.** The sync stays path-based; its deps get the session, and `server.ts` routes them to the local reader or to `remote-files.ts`. `transcriptPath` is one ssh round trip (`buildRemoteClaudeTranscriptLocateCommand`): first the conversation the claude process in the session's remote pane writes now (its `<config dir>/sessions/<pid>.json`, found through the pane's process tree, since a remote host sends Codeman no hooks), else the most recently written `<config dir>/projects/*/<id>.jsonl` of the stored history's conversation, `<claudeSessionId>` and `<id>` (`remoteTranscriptIds`; nothing moves `claudeSessionId` after a remote `/clear`, and no fixed order is right both after a `/clear` and after a respawn that resumed the launch conversation); the result is an absolute path on the host. A read pass is one more round trip (`buildRemoteClaudeEditSourcesCommand`), or several with more than 300 subagent requests (below), with the local reader's semantics: offset clamped to the size, a block with `len` ends at `offset + len`, a new file reads its tail, one budget shared main-first, `onlyRequested` for "Load older", at most 8 KiB of `meta.json` per subagent (within a 4 MiB meta budget per read, which the local reader does not have: a meta comes only with a subagent's first read, so one past the budget is never sent and that subagent shows its id instead of its description). `test/remote-edit-sources.test.ts` runs both scripts on a real `sh` against a temp `$HOME` and compares the read byte for byte with `readClaudeEditSources` on seeded random transcripts, so `/clear` catch-up, "Load older", cursors and the store LRU behave remotely as locally. Every request value reaches the script only after validation (id `[A-Za-z0-9._-]`, relPath `main` or `subagents/agent-<id>.jsonl`, integer offsets) and shellescaped; the scripts run under `sh -c` with `LC_ALL=C`. The config dir is an absolute `CLAUDE_CONFIG_DIR` of the ssh command's environment, else `$HOME/.claude` (claude's own rule); the remote claude runs under `$SHELL -i -l -c`, the lookup under a plain `sh`, so a value set only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen. A round trip's timeout grows with its byte cap (`remoteEditSourcesTimeoutMs`: 30 s plus the transfer at 256 KiB/s), since a forward pass may carry 13 MiB and "Load older" 29 MiB. ⚠️ **Never wakes:** the lookup and the reads go through `buildRemoteEditHistoryCommand` (`buildRemoteFileCommand` with `exec`, so a timeout reaches ssh and not only the local `sh -c`, which on dash stays its parent), the image probes through `remoteProbePaths`, and all of them through `runWithRemoteSshLimit` (`ConnectTimeout=10`), so a sleeping host is a connect timeout and the panel shows `unavailable` with the stored entries; the edit-history read path reaches neither `remote-wake.ts` nor `session-routes.ts`, even transitively (guard in `test/remote-edit-sources.test.ts`). Without hooks there is no `transcript:tool_end`, so an open panel re-reads on `session:idle` and the refresh button only. ⚠️ **A read with more than 300 subagent requests spans several round trips** (`planRemoteEditSourceRounds`, `EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP`). The script is one argument of the ssh line, Linux caps a single argv string at 128 KiB (`E2BIG` at about 1000 subagents), the cursors have no cap and the stored conversation is read first, so one script for all of them left a long history `unavailable`, and not even a `/clear` got past it. The cut is by NAME RANGE, not only by count: the script takes a file no `case` line names for a new one (its tail, its meta), so plain groups would read each other's files as new. Each round trip walks only the names between its cuts (one `awk` per round trip, never a fork per file), so a new file lands in exactly one range. Main comes with the first round trip, budget and meta budget carry over, and a later round trip still lists its files, because a requested file missing from the result loses its cursor. A failed round trip fails the whole read and nothing of it reaches the store; each one takes its own limiter slot. The merged result equals the local reader's, byte for byte (the fuzz test cuts every read at a random size). A read of up to 300 runs no `awk` at all. ⚠️ No `case` inside a `$(…)`: bash 3.2, macOS `sh`, cannot parse it, which is why the names come from a shell function. ### Config backups **Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced). ⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap. ⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since). ⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder). ⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again. ⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a file that does not parse: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. ⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock. ⚠️ **One walk of the folder per pass.** `runConfigBackupPass()` reads the folder once and hands the names to the temp sweep and the manifests to the retention fallback, the change check and the prune (with the snapshot it just made prepended: it is always the newest, by the date floor above). Each of the three used to list the folder itself, so every manifest was read three times per pass, every five minutes, on what may be a network folder; restore lists once too. `test/routes/config-backup-one-walk.test.ts` counts the reads. ⚠️ **Restore verifies everything before it writes anything.** Every file's SHA-256 against the manifest, and the id must match the name pattern (no traversal); a mismatch is a 409 with nothing changed. It also refuses a snapshot folder or file that is a symlink or has a group/world bit: this module only ever writes 0700/0600 real files, so such a thing was planted or loosened later, and rewriting it as 0600 would turn it into trusted config (`clis.json` selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). The mode test is skipped where the filesystem keeps no modes (vfat/exFAT, CIFS, WSL `/mnt/d` without `metadata` report 0755/0777 for everything, and Restore would refuse every backup there): `folderKeepsModes()` decides by creating a 0600 probe file with a random name and `wx`, because the earlier fixed `.mode-probe-<pid>` name let a planted symlink be truncated and chmodded by one Compare click. Compare applies the same checks to the snapshots it reads. ⚠️ **The undo copy comes first, and a partial restore is reported, never hidden.** A `pre-restore` snapshot of the live files precedes the writes; it is not forced, so when nothing changed since the newest backup that backup is the undo copy and is the one reported (a forced copy spent a retention slot on a duplicate). It records the retention in force, so it cannot hide that retention from the next pass. Each file is replaced atomically (temp + rename, 0600). A file that cannot be written (a full disk, a directory in its place) does not stop the others: `ConfigBackupPartialRestoreError` carries `restored`, `failed`, `untouched` and `safetySnapshot`, and the route answers 500 with the error envelope plus those fields (and `restartRequired`) in `data`, the shape the tab-layout 409 uses. The bare 500 it replaced named neither the files already put back nor the undo copy. ⚠️ **What a restore wrote must reach the running server**, on the full and the partial path alike (`afterRestore()` in the route): `intentStore.reload()` (the store caches `intents.json` and rewrote it whole on the next captured prompt, reverting the restore), `reloadCliRegistry()` (a restore of `clis.json` is the one write that bypasses `mutateRegistryFile()`, see CLI registry), and in multi-user mode a `config_backup_restore` admin-audit entry (`users.json` holds accounts, password hashes, roles and bypass grants). `users.json` itself is re-read within a second by the user store. `.env`, `push-keys.json` and `settings.json` (whose watcher toggles only `PUT /api/settings` runs) apply after a restart: `RESTART_REQUIRED`, returned as `restartRequired`. Settings re-reads `/api/settings` into the form after any restore that wrote `settings.json`, partial ones included, because the form still holds the old values and the next Save would PUT them over the restored file. ⚠️ **Compare never returns a credential.** `REDACT_ALL_FILES` (`.env`, `users.json`, `push-keys.json`, `webhook.json` whose URL is a bearer secret, `intents.json` whose prompts carry secrets) report changed key names only; anywhere else a key matching `SECRET_KEY_RE` (token, secret, password, credential, auth, `*key`) is reported as changed without its value, and URL values lose userinfo, query and fragment. Listing returns file names and sizes only. ⚠️ **Admin only in multi-user mode**: all four routes answer 403 for a non-admin and the Settings group is hidden for them (`_applyDoctorAdminGate()`), because the files include credentials. Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/config-backup-restore-ui.test.ts` (vm harness over the real `settings-ui.js`), `test/routes/config-backup-routes.test.ts`, `test/routes/config-backup-unreachable.test.ts`, `test/routes/config-backup-one-walk.test.ts`. ## 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. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ Every xterm key handler (Pane A in terminal-ui.js, Pane B in terminal-tile.js) must call `isUserBoundShortcutEvent(ev)`, which is true only for a chord the user REBOUND a generically dispatched action to: the document capture handler's `preventDefault()` does not stop xterm, so a rebind to e.g. Alt+J would otherwise also type `ESC j` into the PTY. Defaults are deliberately not gated (Ctrl+L must reach the CLI for its redraw), so the gate also skips an entry re-captured onto its own default, `toggleSessionSidebar` (it has its own gate, `shouldToggleSessionSidebarFromShortcut`) and copy-selection. Capturing, re-enabling and Reset all go through `findShortcutConflict`/`findShortcutBindingConflict` (settings-ui.js); Alt+1-9, Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved there. **A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events. **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: 1. The branch lives in `attachCustomKeyEventHandler` (terminal-ui.js) and the **no-selection path returns `true` with no `preventDefault()`**, so xterm still evaluates `Ctrl+C` into `0x03`. Returning `false` alone does not cancel the event either way: xterm's `_keyDown` calls the custom handler *before* its own `cancel()`, which is exactly why the copy path calls `preventDefault()` explicitly (otherwise the browser also runs its native copy on top). 2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide. 3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never 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`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. `opencode` used to be in that list and no longer is: its mouse DECSETs are stripped server-side (`isMuxMouseStripMode`, see the strip-flavours invariant), so a plain drag selects there. 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: 1. **A leading strip uses the width the CLI DECLARES, never one derived from the pane.** `capabilities.transcriptGutter` (CLI registry, a bounded integer) is the whole source: claude and codex each declare 2, no other stock entry declares any, and an entry that declares nothing never has a margin taken off a copy. ⚠️ The lookup keys on the session's LAUNCH mode, not on what is running in the pane: a claude-mode session whose pane has dropped to a plain shell (the CLI exited, or a shell was started inside it) still takes up to two columns off a copy, so a `cat`'d block indented four comes out at two. The ceiling rule below keeps its relative indentation intact and `copyStripMargin` (App Settings, "Trim the pane margin on copy") is the escape hatch, so this is a known limitation, not a guarantee the strip only ever fires on a transcript. The server publishes the map as `window.__codemanTranscriptGutter`, built by filtering `enabledClis()` on that capability rather than by listing ids, and `_cliGutterColumns()` (terminal-ui.js) looks that session's mode up in it, defaulting to the active session and taking an explicit id for Pane B of a split. ⚠️ **Three ways of deriving the width from the text were built and measured, and two of them shipped looking correct.** The selection's own shared indent fired on **73%** of 401,445 three-row windows across 1,010 tracked files, because a three-row window of nested YAML shares an indent for the same reason a margin does. **Painted trailing padding** — a full-screen TUI writes real spaces across the part of a row it is not using, while a shell leaves those cells never-written for xterm to trim — has no false positives and never over-stripped, and is nonetheless a function of pane WIDTH: the padding exists only while a rendered line stops short of the CLI's own layout width, and Claude Code's prose wraps to fill it. Dragging the same two prose rows of one live transcript at five window sizes, the share of rows carrying padding measured 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298 columns, so at every ordinary size the strip did nothing at all while every test and every 282-column measurement said it worked. The **narrowest indent on the surrounding rows** fires at every width and over-strips about 1% of selections, because a file listing inside the transcript can be the narrowest thing on screen. ⚠️ The declared width is a **CEILING, not the answer**: `clean()` strips the lesser of it and the run every selected line shares, so a block can only ever shift as a unit, the relative structure inside a selection survives by construction, and a selection reaching column 0 loses nothing. That is what keeps a `git log` body at its own four spaces inside an agent's two-column gutter. Measured over 1,392,281 selections — every 1, 2, 3, 5, 10 and 20-row window of real Claude screens replayed from live PTY streams at 100, 120, 160, 198, 235 and 282 columns — it over-strips none, breaks no relative indent and alters no text, at 100% of the selections whose own indent covers the gutter. ⚠️ Nothing reads the terminal buffer on this path; the old version scanned up to ~240 rows per `Ctrl+C` to measure a width the CLI can simply state. ⚠️ **The margin strip is not idempotent, so no caller may clean twice.** It takes the lesser of the declared width and the shared run, so a second pass takes up to `margin` columns more off whatever the first left. The trailing trim alone is a fixed point, and `copyTerminalSelection()` relied on that by re-cleaning whatever argument it was handed, which dedented every claude and codex copy twice on the `Ctrl+C` path — the most-used of the four, while right-click, the phone selection button and Auto Copy stayed correct because each hands over the raw selection or reads it live. The gate still tests the CLEANED string, so a padding-only selection still falls through to the PTY, and the RAW string is what travels on. A source-level test pins that branch, because it lives inside `initTerminal`'s `attachCustomKeyEventHandler` closure over a real xterm that the vm harness cannot build. 2. **A COLUMN selection is returned untouched.** Alt+drag makes one (xterm's `shouldColumnSelect` keys on `altKey` alone, and neither `Terminal` Codeman builds passes the one option, `macOptionClickForcesSelection`, that would disable it), and a rectangle's rows lining up is the whole point of the gesture. xterm exposes the mode nowhere public, so the check reads `terminal._core._selectionService._activeSelectionMode` (`SelectionMode.COLUMN` is 3) and cleans normally if a future xterm renames it. 3. **The mid-row flag reads an ORDERED range, and it governs one line.** ⚠️ `getSelectionPosition()` is reversal-aware on the pinned xterm and the review note that called it the raw mousedown anchor is out of date: `CoreBrowserTerminal.ts` reads `_selectionService.selectionStart`, whose getter returns `SelectionModel.finalSelectionStart`, and that swaps the pair when `areSelectionValuesReversed()` says so. Driving a real upward mouse drag through chromium against xterm 6.0 reports the same range as the downward drag of the same rows. `_normalisedSelectionRange()` orders the pair anyway, because the private model one layer down exposes unnormalised fields under the same two names, and a reversed pair would put the mid-row flag on the wrong end of the drag. `range.start.x > 0` then means the first selected line began mid-row and never carried the margin, so that line alone is left out of both the shared-indent ceiling and the strip. ⚠️ This is the ONE thing the mousedown column still decides and it decides it for that line only. In the dropped version it decided whether the first row joined the measurement, which changed what **every** row lost, so the same three rows produced three different clipboard results depending on where the click landed. 4. **The emptiness gate is `trim()`, not truthiness, and it still clears the selection.** A multi-row drag across padding cleans to line breaks alone, which are truthy, and a bare newline pasted into a chat composer submits it. ⚠️ The clear is FEEDBACK, not protection for the interrupt, and the comments that said otherwise were describing the pre-clean code: the `Ctrl+C` gate above now tests the CLEANED selection, so a padding-only selection left set cleans to `''` on every later press and falls through to the PTY as `0x03` anyway. What the clear buys is that a highlight which copied nothing does not linger unexplained, which is also what the toast is for. Tests: `test/terminal-copy-clean.test.ts`. ### 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.** `onSelectionChange` runs 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-level `mouseup` listener installed once in `initTerminal`, 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.writeText` on it, and `document.execCommand('copy')` (the plain-HTTP fallback that `install.sh`'s LAN option lands on) has to run in the gesture's own task. Deferring to a timer or waiting for `onSelectionChange` loses 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 path `preventDefault()`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 second `Ctrl+C` is 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 the `execCommand` fallback (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; plain text is a Codeman-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) handed to that pane's terminal via `terminal.input(framed)`. `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself. `input()` fires onData as user input, which scrolls to the bottom and clears the selection. The main pane's onData bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, which is where the local-echo flush and the delayed write live (#219). A frame wrapped in CSI 200~/201~ does not match the query-reply or mouse-report filters. The terminal is refocused; the paste is not written into the xterm buffer. Two independent routes deliver that event for one keypress, and a browser may fire both: 1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing. 2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well. 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-section`s, each with `.set-section-head` + `.set-section-blurb` + `.set-group`s of `.set-row`s (`.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. Apply (`applyAppSettings()`) sits beside Save in the same actions and saves without closing; it only sets a one-shot flag that `saveAppSettings()` consumes before its first await, so a Save clicked mid-Apply still closes the sheet. **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-section` and `switchSettingsTab(id)` keeps its historical name but SCROLLS instead of hiding. Section order in the DOM is `settings-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-section` and put `.hidden` on 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 inline `onclick` and 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 `/clear`ed 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 harness mark (an agent CLI's `run-mode-dot` logo, a shell's `sh` 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 lines are skipped** in sidebar layout (`_appendLineageConnectionLines` early-returns: `computeLineageTree()` routes through a horizontal strip's reserved row gaps or the vertical rail's reserved `--lineage-vertical-gutter`, and the sidebar has neither; a sidebar 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 rail's Search sessions box and the sidebar's filter box are ONE row filter** (#580): `applySidebarFilter(query)` sets `_sidebarFilter`, the rail box (`setTabRailSearch`, `#tabRailSearch` at the top of `#tabRail`) sets `_tabRailSearch`, and both run `_applyTabListFilter()`, which reads the rows the last render drew and applies the pure `CodemanTabSearch` (constants.js) as classes. Only one needle is ever live, because the rail and the sidebar never host `#sessionTabs` at the same time (`applyTabOrientation()` forces horizontal while the sidebar owns the list); off the rail and out of a reachable sidebar the needle is empty and every class comes off. The rules that keep it a view filter: - **Classes only.** `tab-filtered-out` on a row, and on a `.tab-layout-group` or `.tab-cluster` the filter emptied; never a reorder, a re-render per keystroke, a layout write or a request, so `sessionOrder`, the Alt+N badges, the server order and the tab layout never move. Both render paths end in `applySidebarFilter()`, which re-applies it. The hide rules are layout-scoped like the sidebar's original one (`html[data-tab-orientation='vertical'] .tab-rail ...` for rows, groups and case boxes; `html[data-session-list="sidebar"] ...` for rows and case boxes), so a leaked class can never hide anything on the header strip, which has no box to clear it from. - **Matching.** The rail matches the NAME only (`.tab-name`'s `data-full-name`, a web tab's title, never a URL or folder); the sidebar keeps its name + working directory. Trimmed, case-insensitive substring, lower-cased with `toLowerCase()`, never `toLocaleLowerCase()` (a Turkish locale lowers "API" to "apı"). - **A row with a tab alert is never hidden** (owner decision on #580, the collapsed-header promise again: a prompt waiting on you is never hidden by a view filter). The row goes into the core as `keep: true` when `tabAlerts` holds anything for it (red `action` or yellow `idle`); a kept row counts toward its section, so its group or case box stays on screen, but not toward `matchCount`, so "No sessions match" can show above a lone alerted row. Alerts come and go through `renderSessionTabs()`, whose tail re-runs the filter, so nothing else has to. - **Counts and the tree.** A section's count shows the rows left showing during a search and is restored from the `data-total` it stashed on first touch. In the grouped tree `_tabTreeItems()` skips a hidden group whole (header included), the filter tail re-runs `_applyTabTreePositions()` and moves the one roving stop off a hidden item, but only while a search hides something or right after one changed what shows (both render paths already set them over an unfiltered tree). Only the grouped rail is a tree; the flat rail stays a tablist. - **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes. - **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:<id>` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects. - **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler. - **Drag stays on while searching** (owner decision): searching for a tab and dragging it into a group is a useful flow. A drop uses the same anchor logic relative to the visible rows, so a row can land between visible neighbours with hidden rows on either side. - **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`. Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.test.ts` (browser suite, not in the gate; it installs the real `setupEventListeners()` so the capture-before-inline Escape order is the shipped one). ⚠️ **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 and the phone swipe are the two places 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). That walk is `_visibleTabRows()`, and the phone swipe shares it: `SwipeHandler` calls `nextSession({ visual: true })` / `prevSession({ visual: true })`, which step through `_stripSessionOrder()` (the grouped tree's session rows via `_tabTreeItems()`, otherwise `_visibleTabRows()`, web tabs skipped), so a swipe lands on the tab drawn next to the current one in the case and state layouts, a sorted rail and named groups; Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep `sessionOrder`. An active session the list does not draw steps in at the first row (next) or the last (previous), and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). 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 things the user steps with their eyes (the arrow keys and the swipe) follow 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 an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — 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 `TerminalTile` 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 the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. 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'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) 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 goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `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 keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. That includes the click report a tile hand-encodes for a mouse-strip CLI: `TerminalTile._installClickListener` passes `ephemeral: true` in its target, which `_sendSyntheticSgrTap` honours, while the primary pane's own report (no flag) stays on `_sendInputAsync` as before. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh resets the screen only once its own capture is in hand), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh settles it itself (its replay's queued `\x1bc` would wipe a marker written now, so it re-owes the marker on a closed socket and writes the one copy below its replay, and a refresh that writes nothing stamps the one still owed). The marker stays owed instead of being written twice; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously, so an async-parse fake there pins what ends up on screen. Anything that wipes the terminal on a closed socket (a pull's or a refresh's `\x1bc`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ A server `{t:'c'}` frame is a REFRESH, never a bare `clear()` (`_onLiveClear()` calls `_refreshBuffer()`): its one emitter is a fresh Claude pane's first prompt (`Session.startInteractive`, "refresh after startup"), the primary pane answers it with a refetch and replay (`_onSessionClearTerminal`, which stands aside while the grid is open), and xterm's `clear()` keeps only the cursor's row, so a Claude session Run into the grid or Attached in a tile came up as a near-empty tile that an idle Claude never repainted. As a refresh it coalesces behind a load already running (a pull's held frames included) and waits its turn in the grid's load queue. ⚠️ A refresh (`{t:'r'}`, `{t:'c'}`, a reconnect) runs in the primary pane's order (`_onSessionNeedsRefresh`): fetch FIRST, so the pane keeps its last frame through the round trip and through a grid tile's wait; hold live frames from the response on (`_liveQueue`; the body read of a bounded window, a grid tile's or a shell's, gets the 10 s budget, while Pane B's unbounded `full=1` keeps the request's); then the queued in-stream `\x1bc` immediately before the replay, then the held frames that arrived after the response. Never xterm's `clear()`: it is synchronous while queued bytes are parsed after it (they fused into the snapshot) and it keeps the cursor's row and column, where the capture (raw rows, no home) then started. A failed, aborted or empty fetch writes nothing and resets nothing. ⚠️ Live output is flow-controlled per pane (`_writeLive`), because the server applies no backpressure and a flood a tile cannot parse as fast (a shell tile running `cat` on a huge log) used to pile up in xterm's own queue until its WriteBuffer threw past 50M code units and onmessage's catch lost the frames silently: unparsed output is counted through each write's callback, and held output by the queue, against `TerminalTile.LIVE_BACKLOG_BUDGET` (4 MiB; deliberately NOT the primary pane's 128 KB, which caps its own rAF-paced queues, while xterm itself paces a tile and a tight cap would blank-and-reload it on ordinary bursts). Past it a frame is dropped, the tile stops writing onto the hole, and one refresh, debounced and bounded by the primary pane's own rule (`CodemanDroppedOutput`: 2 s, at most `DROP_RECOVERY_MAX_ATTEMPTS`, never retried after a deadline abort), recaptures the screen; the flag clears only once a capture taken after the last dropped frame has replayed. A write xterm throws on is the same drop. Past the bound the flag is released (the tile is never left frozen), a reconnect starts the accounting over (an epoch makes callbacks from before it count nothing) and drops a pending recovery, since its own refresh replaces the screen, and `destroy()` cancels it. ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; a forced fit (Redraw: Ctrl+Shift+R, the header button, through `restoreTerminalSize`) carries the primary pane's `f` flag, without which `Session.resize` skips a size equal to the one it last applied, and `fit()` returns whether a frame went out, so Redraw reports success only for a resize it sent (a detached session or a socket that is down gets a warning instead); the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. ### Tile grid **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives, reports the cells whose sessions went away as `freed`), `restoreTileGridCells` (a stored grid as it comes back), `rankTileSessions` (the order a fresh or filling grid takes sessions in), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. ⚠️ **One load queue.** Every capture a tile fetches (initial load, refresh after a reconnect, server `{t:'r'}` refresh, server `{t:'c'}` refresh, dropped-output recovery refresh, shell history pull) goes through the grid's ONE `TileLoadQueue` (terminal-tile.js, the tile's `scheduleLoad` option): concurrency 1, a history pull first, then the focused tile, then reading order; a destroyed tile's waiting loads are dropped unrun and destroy() aborts its running fetch. A load holds the slot only while xterm parses its replay: `writeChunked` queues every 32 KB slice at once (one 1 MiB window at a time, since xterm's write queue throws past 50 MB) and settles on the callback of a write queued behind them, never one slice per animation frame (that pacing held the slot about a second per 1 MiB tile). ⚠️ destroy() settles a replay in progress (`_cancelReplay`): a disposed xterm never runs that callback, and an unsettled replay would hold the tile's flag and the grid's queue forever. Each `GET /api/sessions/:id/terminal` runs synchronous tmux calls, so N tiles loading at once (after a deploy restart all N reopen within a second) would stall every WebSocket and SSE stream on the server back to back. Grid tiles load the BOUNDED window (`full=1&tail=TERMINAL_TAIL_SIZE` for a TUI, `SHELL_LOAD_BASE_QUERY` for a shell, the shell load without its fixed `lines=`; the `boundedLoad` option), and every full capture of theirs (a TUI load, a shell load, a shell history pull) also sends `lines=<TILE_SCROLLBACK + rows>`, so tmux reads no more history than the tile keeps instead of the whole history limit (the route's optional `lines` bound, docs/api-reference.md; the split's Pane B sends none for a TUI and `SHELL_LOAD_QUERY`'s `lines=10000` for a shell), keep `TILE_SCROLLBACK` lines and their own per-device font (`codeman-tile-font-size`, Ctrl +/- while the grid is open). A refresh fetches at its turn, not when asked, and resets the screen with the queued in-stream `\x1bc` only once its capture is in hand, right before the replay (never xterm's `clear()` before the fetch), so a tile keeps its last frame through its wait and its own round trip; a failed, aborted or empty fetch writes nothing and resets nothing, and the tile keeps its last frame and every held live frame. ⚠️ **Selections.** The tile branch of `selectSession` sits right after its "already active" early return: a tiled id is FOCUSED (`_selectTiledSession`: no cleanup, replay, resize or main socket; the shared `_refreshSessionPanels`; only a user-initiated pick acknowledges the idle alert). A USER-initiated pick of a session that is not tiled leaves the grid for the single view, remembered (decision 1), and so does `leaveTiles` (a followed `#session=` link); an `auto` pick never collapses it. Whoever calls `closeTileGrid({ reselect: false })` and then selects must null `activeSessionId` first, or `_cleanupPreviousSession` saves the parked terminal's stale content as a snapshot. App-driven fallbacks pick a tile: `closeSession` captures the neighbouring tile BEFORE it tears the tile down (like `wasActive`) and focuses it with `auto` before the delete request goes out; the `_onSessionDeleted` wrapper does the same for a delete from elsewhere, and leaves a close from this tab (`_closingSessions`) to `closeSession`. Ctrl+Tab and Alt+[ ] cycle the tiles. A popped-out (detached) session leaves the grid. Moving focus off a zoomed tile restores the grid (tmux `select-pane`); an automatic zoom (window too small for the minimum tile) follows focus instead. ⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. ⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, EXACTLY as the user left it (decision 11: `restoreTileGridCells` keeps its cells and holes, its focus and a zoom the user chose; `_openStoredTileGrid` puts back its divider sizes and count; an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), never filled to the remembered count and never trimmed to the window (`_applyTileLayout`'s automatic zoom shows the focused tile until the window fits, the arrangement kept); (b) else an open split's two sessions; (c) else the open sessions as `rankTileSessions` orders them (`_tileGridRanking`, tile-grid.js: no detached ones; WORKING first, the most recently started turn first, keyed off `lastSubmitAt` ONLY because a working pane's `lastActivityAt` is always "now"; then the ones NEEDING INPUT, `needs`/`waiting`, the red and yellow tab alerts, most recent first; then the rest by `sessionActivityAnchor`, newest first; a 0 stamp last, tab order the final tiebreak; the states are `_mobileOverviewState`'s, and a page without it falls back to tab order), the active session always included and focused; then `tileGridSetForCount` trims (b) and (c) from the end (the session to focus kept) or fills them from the ranking to the remembered count (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`). ⚠️ A stored grid's freed cells (a session gone or popped out since) are filled from the ranking first, then other empty cells, only while it holds fewer tiles than its stored `count`; a hole the user made stays. A count picked in the menu with the grid closed (`_activateTileGrid({ count })`) re-forms a stored grid to it (`reformTileCells`, the added tiles in the empty cells first). Ctrl/Cmd+click with the grid closed opens what the toggle would with the clicked session focused, never past the count: it joins while the grid holds fewer, else takes the last tile's place. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. ⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), each tile and each empty slot placed explicitly in its cell; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. ⚠️ **Moving tiles** (owner request): a tile's header is a native drag handle (`_installTileMoveDrag`; its free area only: where the press landed is noted in the capture phase, since `dragstart` targets the header whatever was pressed) through the same drop targets. It carries `application/x-codeman-tile`, never text, and sets `_draggedTileId`, never `draggedTabId` (the tab strip would reorder on it); `dragend` (Escape, a drop elsewhere) moves nothing and clears what the drag painted, and removing the tile or closing the grid mid-drag ends it. ⚠️ The header focuses its tile on CLICK, never on pointerdown (the tile's pointerdown skips presses in the header; the body keeps press-to-focus so focus moves before xterm sees the press), so a cancelled drag changes neither focus nor the idle alert; the rename input stops its own clicks. ⚠️ Each drop target says what it accepts (`_acceptTabDrops(el, onDrop, { accepts })`): a tile any session but its own (its own is held but refused, `dropEffect: 'none'`, no highlight), an empty slot anything. ⚠️ **The grid is cells** (owner: an empty cell can be any cell): `grid.cells` (a session id or `null` per cell, cols x rows of them) is the ONE source of truth and `grid.ids` a getter deriving the tiles in reading order, so never write `ids` (a leftover write in the non-strict `Object.assign` methods would be silently ignored). The shape comes from the tile count; within it a tile moved onto an empty cell goes there and leaves its own cell empty (`_moveTileToCell`), a removed tile leaves a hole, an added one takes the first hole or the cell a tab was dropped on (`_placeTile`), and a shape change goes through the pure `fitTileCells` (keep row and column when all fit, else pack). Each slot carries its cell (`data-cell`). The focus chords run `tileInDirection` over the CELLS (never onto a hole; the nearest tile in the nearest row for up/down), the Move Tile chords `tileCellInDirection` (the adjacent cell: into it when empty, a swap when not). Every move (that drag, a tiled tab's drop, Move Tile) goes through ONE `_reorderTiles`: no remount, reconnect or reload, divider sizes belong to the cells, and only a tile whose cell size changed fits, synchronously, once (never the debounced refit of every tile). Moving is off while a tile is zoomed (`draggable` off, a tiled tab's drop refused) and with a single tile. ⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot <cliId>` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. ⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. ⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; App Settings → Appearance, its row shown on desktop only). A tile leaves the grid for a window of its own, and comes back into any Codeman window's grid, by dragging, with nothing new on the server: a pop-out is another client of the same session (`detachSession`), and a tile is a `TerminalTile` in whichever window has it. (1) A header let go OUTSIDE the window opens it with `detachSession(id, placement)`, the tile's size (at least 480x320), its header near the drop point, after `TILE_TEAR_OFF_SETTLE_MS` (120 ms) that a claim from another window cancels. "Outside" is the dragend's own point off the viewport with `dropEffect: 'none'`; where a browser reports no point (0, 0) it is the page's record of a `dragleave` at the viewport's edge with nothing entered, cleared by any later `dragover`. A drop that any target took (`dropEffect: 'move'`, in this window or another) never opens one, a drop inside on nothing changes nothing, and with the setting on a lone or zoomed tile drags too (out only: no target here takes it). (2) A header dropped on ANOTHER Codeman window's tile or empty cell moves it there: that window's `_acceptTabDrops` takes a drag that is none of its own and carries `application/x-codeman-tile` (`_isForeignTileDrag`; the id is only readable on drop), docks it through the target's own drop (`_dockForeignTile` → `dropSessionOnTile` / `dropSessionOnSlot`, so `_reorderTiles` stays the one move path) and posts `tile-adopted` on the `codeman-windows` channel. Only the window the tile was dragged FROM acts on that (its drag still settling, or ended within `TILE_ADOPT_WINDOW_MS`): the tile goes with `focus: false`, and a last tile closes the grid onto the welcome screen, never onto that session, since two windows sizing one PTY garble it. (3) A pop-out's title (`#soloSessionTitle`, `_installSoloTileHandle`) carries the same type, so it drags back onto any grid: docking a popped-out session `_redock`s it first (a detached session never mounts), posts `close-request`, and ignores that pop-out's `detached` answers for `TILE_DOCK_GRACE_MS` (`_tileDockedRecently`), which would otherwise take the new tile straight back off. A pop-out that gets `close-request` is RELEASED (`_releaseSoloWindow`): it posts `redocked` at once, stops answering roll-calls, then closes; one a script may not close (opened by typing its URL) says where the session went instead of staying a pop-out every dashboard would mark detached again. A tile's ⋯ menu offers "Open in a new window" with the setting on (`detachTile`, placed over the tile): the keyboard's way to the drag. ⚠️ Chrome keeps a script-opened window on the opener's screen unless the page holds the Window Management permission, so a drop on another monitor opens at this screen's edge (drag the window across from there), and Wayland ignores the position. The gesture overlay reaches all of it through `tileAtPoint` / `canGrabTile` / `tileDropTargetAt` / `dropOnTileTarget` / `isOverTileGrid` / `detachTileAtPoint`, never move logic of its own; its pop-out has no click behind it, so a popup blocker refuses it unless the site's pop-ups are allowed (detachSession's toast says so, and the tile stays). ⚠️ Known limits: (a) every Codeman window in one browser shares ONE stored grid (`codeman:tile-grid`, localStorage), so reloading the window a tile left can restore the other window's grid (the window that loses a tile to another holds `_tilePersistHold` and never writes over it, but the next change in either window wins); (b) a drag between two browser PROFILES docks the tile, but neither its pop-out nor the source tile hears about it, since the `codeman-windows` BroadcastChannel is per profile; (c) a drag-out held longer than the browser's user-activation window (about 5 s in Chrome and Firefox, about 1 s in Safari) gets "Pop-out blocked" and the tile stays. Tests: `test/tile-grid-detach.test.ts`. ### 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`. The tile grid's verbs (a tile pinched anywhere on it and carried onto another tile or an empty cell; a tab carried onto the grid joins it there; with Detach Tiles a tile let go outside the grid pops out) ask tile-grid.js's gesture helpers (see Tile grid, Detach Tiles) and never move tiles themselves, so the hand and the mouse cannot disagree about a drop. ### 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](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo). The sticky keyboard latch survived until the device was opened again, and rotating any phone hit it too. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. The palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px; its compound rule lives INSIDE the 600-768px band whose mobile.css shorthand it composes with. The fold measurements were taken at 393 and 500px widths. Device profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. ### Terminal touch gestures: link taps and text selection **Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). ⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. ⚠️ Only up to the swipe's own drift limit (`SwipeHandler.maxVerticalDrift`): past it the swipe has given up, so the drag is handed back to the scroll (`y`). Otherwise a scroll whose first few pixels lean sideways (a thumb arc) locked as `swipe` and did nothing at all. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it. Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/terminal-touch-swipe-axis.browser.test.ts` (the real touch listeners: the drift handback and a clean swipe, browser suite), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite). ### Entrance animations **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-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 its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section. ⚠️ 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. **Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`. ⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both. ⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible. ⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says. ### 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. ⚠️ **Grouped by state, the active tab can move without being switched to.** The strip orders its tabs in one `order` band per state (`CodemanTabTriage`), so the active session changing state (a prompt sent: idle to working; a permission prompt: needs you) slides its chip into another band while `scrollLeft` stays, and the tab in use left the screen (measured at 390px: x 165 to -870). `_noteActiveTabBand()` records the active tab's band (read off the element, also from `_updateActiveTabImmediate()`, so the pass right after a switch counts: viewing a waiting tab spends its alert and drops it to idle) and both render paths reveal it when that band moved, in the single scrolling row only (`_isScrollingTabRow()`). Keyed on the BAND, never the raw order value: another tab entering or leaving the active tab's band shifts that value by one, and another tab's move must never yank a strip the user is browsing. The incremental path reveals after `updateTabOverflowMode()`, and only from the branch that reached `_syncTabTriageChrome()`, so a pass that falls through to a full rebuild mid-loop leaves the record for the rebuild to compare. Tests: `test/tab-triage.test.ts`. ⚠️ **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. ⚠️ **The opt-in tab "+" is a third icon in that cluster**: with **New Session Button on Tabs** on (`showTabNewButton`, per device, off by default; `html.tabs-show-new`) the active phone tab shows a 28px "+" between the gear and close, and `html.tabs-show-new .session-tab.active .tab-name` grows the reserve to 68px (gear 32 + "+" 28 + close 20 less close's 2px margin needs a 64px label, 66 with the test's rounding allowance), still under the 80px `max-width`. Growing the reserve while the setting is on is fine; shrinking the base one never is. The same test recomputes it with the "+" counted. ⚠️ 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. ### Tab layouts (by state, by case, ledger) **Tab layouts** (`tabArrangement`, per-device, default `classic`, the others opt-in; Discussion #426): App Settings → Appearance → Tabs → **Tab Layout**. Four values: `state` (option C), `case` (option A), `ledger` (option B) and `classic`, the strip as before. `<html data-tab-arrangement>` is stamped pre-paint and by `applyTabOrientation()`; the render paths read it through `isTabTriage()`, `isTabClusters()` and `isTabLedger()`. Named groups in the vertical rail (owner tab layouts, `_projectTabGroups()` non-null) win over both groupings, and the grouped tree renders as before. **By state.** The list is split into four groups: **needs you** (a permission or question dialog; a failed session joins it), **waiting** (idle prompt pending), **working**, **idle** (also ended sessions, an agent that exited inside a live pane, and web tabs). Idle is `quiet` (`TAB_TRIAGE_GROUPS`): its heading element still exists, because it anchors the row's `order` band and, as the lead, holds the row's place beside the brand, but it draws no label or count (`.tab-triage-head--quiet`). `tabStateOrder` (`urgent-first` default, `urgent-last`) decides which end they start from; only the group order flips, never the rows inside a group. The desktop header strip draws a row per group with its label left-aligned in a column that `_sizeTabTriageGutter()` measures to the widest label on screen (re-measured only when the label text changes, when the strip starts wrapping, and once on `document.fonts.ready`), so no row carries a fixed gutter's worth of empty space. The brand leaves the flow and sits over the strip's top-left corner (`.header:has(...)`), so the first row's heading (`.tab-triage-head--lead`, natural width) is pushed past it by `--tab-triage-brand` (a ResizeObserver keeps that width, so no render pass reads layout for it) and every later row starts at the edge, under "Codeman"; the flat rail and the sidebar draw a section per group; the tablet strip (600-767px) keeps its scrolling row with the headings as inline dividers; phones keep their chip row in group order with the headings hidden (mobile.css). ⚠️ Only the wrapping strip reads the label column, so `_sizeTabTriageGutter()` measures nothing (and forgets its key) while the strip does not wrap, skips parts that are not laid out (a hidden heading measures 0 per part, and the 5px gap between label and count used to turn that into a 15px column cached as if measured), and `updateTabOverflowMode()` calls it right after deciding to wrap, because a phone turned to landscape or a foldable opened crosses 768px with no tab render behind it (the resize handler only calls `updateTabOverflowMode()`). Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`, the home screens' own; the fold into four groups and the order bands are pure in `CodemanTabTriage` (constants.js, `computeTabTriageLayout()`). ⚠️ **By state is the flex `order` property, never a DOM reorder**, exactly like the sorted rail: `#sessionTabs` stays in `sessionOrder`, so the Alt+N badges, drag, the keyboard walk (which sorts by COMPUTED order) and the sidebar filter keep reading the list they always read. Each group owns a band of `TAB_TRIAGE_STRIDE` order values: its heading, its rows, its web tabs, then the row break that ends the header line. The headings and breaks are `aria-hidden` direct children of `#sessionTabs`, reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, because a state change is an incremental pass and can still empty a group or fill a new one. Inside a header row tabs keep TAB order; a sorted rail ranks each section the way it ranks the flat rail. The header strip only wraps into rows on desktop, where `updateTabOverflowMode()` forces `tabs-auto-wrap`; the breaks only display in a wrapping strip, since a `flex-basis: 100%` break in a nowrap scroller would steal width. **By case.** Each case's tabs sit in one `.tab-cluster` box (`_renderTabClusters()`), in the order the case first appears in the tab order; membership is `_mobileOverviewCaseFor()` (longest case path that prefixes the working directory), else the directory, else the session alone. A box with two or more tabs is labelled (swatch, name, count) and its generated `w<n>-<case>` names drop the `-<case>`, which stays in the DOM in a `.tab-name-case` span only `.tabs-clusters` hides; a one-tab case gets only the swatch in the header strip; a web tab gets a box of its own. The colour is `--session-<colour>` by a stable hash of the case key (`CodemanTabClusters.colorFor()`), so nothing is stored. The rail and the sidebar draw every case as a labelled section; phones dissolve the boxes (`display: contents`) into the one chip row. ⚠️ **The boxes ARE a DOM grouping**, unlike by state, which is safe only because a tab changes case far less often than it changes state: `_tabClusterLayout().key` is the whole structure, and the incremental path rebuilds whenever it differs from the last full render's. The keyboard walk keeps boxes together (box index first, then computed order, since a sorted rail orders rows inside each box). Drag only reorders inside one box, as it only reorders inside one state group (`_isTabDropAcrossGroups()`). **Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list. **Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite). With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. ### Header stats styles **Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `<html>` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. The opt-in host readings after MEM (GPU, VRAM, GPU MHz, CPU MHz: `.stat-item` elements that start `hidden` and are shown by `_updateHostSensors` in panels-ui.js) rely on `html[data-header-stats] .header-system-stats .stat-item[hidden]` in styles.css outranking the Tiles style's own `display` on `.stat-item`; keep that selector's specificity above any style rule that sets `display` on a stat item, or the hidden placeholders (`--%`) reappear. Those four carry `.stat-item--host`, which the header divider's density rule (`data-header-density`) excludes from its label hide: their values read like CPU's and MEM's, so the label is the only thing naming them. At Compact + `tiny` (rings only, values hidden) a reading with no ring is hidden outright rather than left an empty flex item: `.stat-item--ringless`, static on the two clocks and toggled by `_updateHostSensors` on VRAM while its total (and so its ring) is unknown. Tests: `test/header-host-sensors.browser.test.ts` (browser suite, not in the gate). ⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it. ⚠️ The extra parts (`.stat-ring`, `.connection-tile`, `.pu-ring`, `.pu-meter`) are always rendered and hidden by default in styles.css, which is what keeps `classic` looking exactly as before. ⚠️ A tile is a three-row grid (9px label, 14px value, 2px bar) with pixel line-heights, 36px tall like the header, in the bundled JetBrains Mono. The first version laid the bar over the bottom of a fixed 28px tile, and a taller system mono (SF Mono) pushed the value into it; the bar is now a real row, so the height comes from the layout, not the font. The WS tile keeps its grid on the inner `.connection-tile` span, because `_updateConnectionIndicator()` writes the indicator's own `display` inline. The tile words come from `_connectionTileWords()`, DERIVED from the connection descriptor rather than added to it, because `test/connection-indicator.test.ts` pins the descriptor's exact shape. Plan rings and meters clamp their fill to 0-100 while the label keeps the real number. Tests: `test/header-stats-style.test.ts`. ### 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`, the frame's line separator, so the lines weld into one, pasted inside a second pair of markers, and get submitted. ⚠️ 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()`. ⚠️ A roving-tabindex widget defeats that arm: the grouped rail's tree parks every item but one at `tabindex=-1`, and its group headers are not buttons, so the selector lists `[role="treeitem"]` by role (pinned by `test/tab-layout-rail.test.ts` and `test/tab-activation.browser.test.ts`). **(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. ⚠️ **An accessory key that keeps the keyboard up must not take focus on the press.** The keys that act on the terminal and leave the keyboard open (`ACCESSORY_REFOCUS_ACTIONS` in keyboard-accessory.js: scroll, arrows, Tab, Shift+Tab, Ctrl, Esc, clear input and the rest) cancel the `mousedown` default, the same pattern as the Attach input, so the hidden textarea keeps focus through the press; the `terminal.focus()` in the click handler stays as the fallback. Letting the tap blur the terminal and refocusing it afterwards made every such key start a keyboard dismissal, a blur/focus pair per tap that shifted the toolbar each time. A first tap on clear/compact only arms the confirm and is treated the same; the confirming tap, and keys that open a dialog or dismiss the keyboard on purpose, keep the default. Pinned against the real module by `test/accessory-keeps-terminal-focus.test.ts`. ⚠️ **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". ⚠️ **The connect attempt has its own deadline, because the watchdog cannot see it.** An `EventSource` whose request never gets response headers (a phone resumed on a VPN or tailnet, a proxy that accepted the socket and went silent) stays `CONNECTING` without firing `open` or `error`, so `onerror` never schedules a retry and the status is never `connected` for `computeSseStale()` to judge: the page sat on "connecting" until a reload. `connectSSE()` therefore bounds each attempt by `SSE_CONNECT_TIMEOUT_MS` (15s, exported as `CodemanSseStale.CONNECT_TIMEOUT_MS` in constants.js; read once into `_sseConnectTimeoutMs`) and, if `open` has not fired, retries through `_handleSseConnectionFailure()`, the former `onerror` body, so a hung attempt gets the same backoff, status change and "Retrying in Ns" countdown as an errored one. ⚠️ The timer is cleared in exactly three places: at the top of `connectSSE()` **before** the offline early return (every path into `connectSSE()` supersedes the previous attempt; removing that clear fails the offline case in the test), on `open`, and in `_handleSseConnectionFailure()`. ⚠️ Both the timeout callback and `_handleSseConnectionFailure()` return early for an `EventSource` that has since been replaced, so a late error and the timeout never count one failure twice, and the timeout also skips a connection whose `readyState` is already `OPEN` before its `open` callback ran. ⚠️ The window `offline` handler deliberately leaves the timer running, since `navigator.onLine` can report offline on a working network; only a `connectSSE()` made while offline cancels it. Tests: `test/sse-connect-timeout.test.ts` (the real constants.js + app.js in a `vm` context with a fake `EventSource` and fake timers). ### 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** (one documented exception, the keyboard row hold in the next paragraph). 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. **The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **Measured** on Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's size printing 100 numbered lines: five keyboard cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes. ### 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`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), 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), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), 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), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). ## 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 logins/IP → 429 (15min decay). Multi-user mode instead has two gates checked before scrypt, so past either even a correct password from that IP gets 429: 50/IP (`AUTH_IP_FAILURE_MAX`) and 10 per (username, IP) (`AUTH_FAILURE_MAX`; the same account still logs in from another IP). A 10-per-username bucket (multi-user only) is checked after the verify: 429 to failed guesses from the 10th on, from any IP, never to a correct password. 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 a login (`CODEMAN_PASSWORD` or multi-user mode) unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1` / `--allow-unauthenticated-tunnel` (#706; until then the guard took `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`, COD-55, which `install.sh` writes for every passwordless LAN install, so the bind acknowledgement silently published the tunnel too) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9). With no login the browser asks BEFORE every enable (welcome link, tunnel panel, an App Settings save that turns it on) through `_confirmTunnelEnable()`, reading `loginConfigured` from `GET /api/tunnel/status`, and on confirm sends that flag; the 403 handler stays the fallback when the status read fails (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 (env `CODEMAN_MAX_SESSIONS`, 1 to 462), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`. ### 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. ⚠️ **The kill path's waits are deadlines, not sleeps** (`waitForProcessesExit()`, `utils/process-exit-wait.ts`): `Session.stop()` (PTY client, 100 ms), and `killSession()`'s children (200 ms), process group (100 ms) and final verify (2 s) each stop waiting as soon as the processes are gone. They used to be fixed sleeps plus a 100 ms verify poll, ~0.45 s per close even for a session whose processes died in a few ms. `isProcessRunning()` counts a zombie as exited (`/proc/<pid>/stat` state `Z`/`X`), since `kill(pid, 0)` cannot tell one apart and a zombie only awaits its reaper. Claude itself takes ~0.8 s to exit on SIGTERM or SIGHUP (measured 2026-10-10), so the process-group deadline still ends in SIGKILL for a claude pane, as it always did. `tmux kill-session` and the pane-pid lookup on this path are async, never `execSync` (tens of ms of a frozen server per close). `killSubagentsForSession()` makes ONE `getClaudePids()` scan for all of the session's active/idle subagents, never one `pgrep` per agent (~85 ms each with ~100 matching processes). ⚠️ 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 `rmSync`s 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`. ⚠️ It also refuses everything unless HOME IS the throwaway dir `test/setup.ts` made (`isSandboxedHome()`: its basename starts with `TEST_HOME_PREFIX`, `codeman-vitest-`, the one thing a real home cannot have). "Under HOME" alone is vacuous when setup never ran: on 2026-10-10 a bare `vitest` run had the developer's real home as HOME, every real file was "under the test HOME", and the guard written to prevent exactly that deleted the real linked-cases registry. `isUnderTestHome()` (for single-file deletes such as `linked-cases.json`) carries the same requirement, and the shared route harness `createRouteTestHarness()` calls `assertSandboxedHome()`, so a route test built on it refuses to start against a real `~/.codeman` (a route test that builds its own app from `createMockRouteContext()` is not covered by that). ⚠️ A bare `npx vitest` (no `--config`) resolves the root `vitest.config.ts`, which re-exports `config/vitest.ci.config.ts`, so it runs `setup.ts` and gets the temp HOME like `npm test`. Vitest looks for its config by walking up from cwd, so this holds from `test/` as well, while `packages/xterm-zerolag-input` keeps its own config. Before that file existed a bare run found no config, ran without the setup file, and wrote fixtures into the real `~/.codeman` (#640: a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values). `test/test-env-isolation.test.ts` pins it; a `--config` pointing at a config without `setupFiles` would still skip the 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. **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, 2026-11-05). Everything Codeman writes was always under `$HOME`; administrator rights were only ever spent on prerequisites. `resolve_admin_mode` decides once per run whether those rights may be asked for: root yes; a Mac account outside the `admin` group no (unless a Homebrew it can write to exists); on Linux no without a `sudo` binary, or when the account is outside `sudo`/`wheel`/`admin` AND `sudo -n true` fails. ⚠️ It is never decided by `sudo -v`: that prompts, and a refused password from an account outside sudoers is reported to the administrator. A refused password on the system route offers this route instead of ending the run (`sudo_session_try`). ⚠️ The group test is a judgment, not a fact: no probe can read sudoers without reporting to the administrator, so an account sudoers lists by name, with a password, lands here too. `NO_ADMIN_REASON` must therefore say the rights could not be confirmed (`NO_ADMIN_UNCONFIRMED`), never that the account may not use `sudo`, and every message that ends or sums up the route carries `no_admin_override_hint`, which names `CODEMAN_NO_ADMIN=0` for that reason alone (`--no-admin` asked for the route; a `sudo` that is missing or refused has no way back). On this route Node.js comes from nodejs.org (`latest-v<major>.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js. ⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting. **Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli <id>|none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent <id>` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and Run reads that memory, so `mark_cli_installed` touches `cli-installed` in the data dir and `resolve()` leaves the backoff when that file's mtime changes. ⚠️ That check is one `stat` inside the backoff branch and buys one run of the chain. Never replace it with a re-walk of PATH during the backoff: that puts a synchronous stat of every PATH entry on the request path (#516) and executes candidates already refused. The app never restarts the service for this. Only `install.sh` writes the marker, always under `~/.codeman`, so a CLI installed by hand, or another `CODEMAN_INSTANCE`, waits out the backoff as before, and so does a server older than this. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`). ⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/<ref>.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `<install dir>.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log. ## Agent inspection and guarded submission These three operations are experimental across both CLI and HTTP, including the `/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule in `docs/versioning-policy.md`, so do not infer stability from the URL prefix. ⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming `agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The gate runs first, before session lookup or body validation, and reads `settings.json` fresh on every request, so a toggle applies without a restart. `codeman agent inspect|stage|submit` pass the 403 through verbatim. `agent-session-routes.ts` is registered by `WebServer` with the other route modules. Ownership is checked before transcript/pane/receipt lookup. Inspection uses strict native metadata attribution, bounded traversal, separate prefix and candidate-metadata budgets, bounded path probes before canonical reads, and complete UTF8 JSONL records. A full page advances its cursor without claiming a blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane geometry is excluded so changing devices does not invalidate transcript paging. Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8, under the global 1,024), so one polling agent retires its own oldest cursors instead of evicting every other caller's from the shared FIFO. ⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store walk per call. The first call walks (negative-filter prefixes read 1 KiB first, 4 KiB only when that window holds no complete `originator` field; non-UUID file names are skipped unread), then the attributed `{path, dev:ino}` and its strict metadata are remembered per session (`attributionMemo`, bounded LRU). A later call re-verifies only that file's head, then scans just the date directories not older than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is never pruned) for a second candidate, which is how a codex `/new` or a new owned rollout beside a configured resume is still caught. A head that no longer verifies drops the memo and falls back to one full walk; a verified head whose incremental scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it and answers unattributed without one, because the full walk reads a superset of that scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422 OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and error code only, never the path. Staging requires a fresh, known composer/caret capture. The composer glyph and footer are registry data (`capabilities.workDetect.promptGlyph` and `composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI declaring `composerFooterLine` takes guarded input (codex today). Codex's footer words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. Before any write, available columns are calculated from actual captured geometry and the rendered prefix, including indentation; reaching the last column or wrapping is refused. A message starting with a `composerCommandPrefixes` character or whose last word starts with a `composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a frame with a `composerPopupLine` row in the block just above the composer is refused at stage and submit: codex draws its popups there and keeps its footer, so the footer alone read `› /stat` with the command list open as idle, and Enter ran `/status`. ⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS` 50) for the first frame with the same generation, `revision === before + 1` and `composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys and the first capture is often from before them; another writer or a lifecycle change ends the wait at once, and every refusal after typing says the message may be in the composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call (`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to the two calls only for output it cannot split; a combined call that THREW goes to the capture's catch and is never rerun (a hung tmux would block the event loop for a second timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`, `_inGuardedTurn()`), and written in arrival order when the lease is released: the browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written; a held async write (mux write, named key) holds everything behind it until it settles (`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with `1013` and applies nothing later from it, so the browser resends in order. `POST .../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain, since its browser drainer is serialized and retries; untagged input there (the phone accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue it again behind later input and wait on itself). `write()` refuses during the lease only, since the drain writes held input through it, so a direct `write()` caller such as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and an Enter within 120 ms of one becomes a newline while submit would report it delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and bounded ephemeral identities only, expire, and are consumed before awaited submission, including failures. Submission additionally binds input and geometry revisions and rechecks the exact composer/caret at the conditional Enter boundary. The route must retain the Session snapshot's staging generation rather than replacing it with the inspection generation. Managed input and auxiliary named keys participate in the same pending/lease boundary: they wait for the lease, and only a full hold queue refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are not edits. Never retry ambiguous Enter delivery or clear an unverified composer. The ordinary SubmitVerifier uses the shared write boundary, so each re-press updates `lastSubmitAt` just like any other Enter. ## Discord bridge `src/discord-bridge.ts`, `src/web/routes/discord-routes.ts`, `src/cli-notify.ts`. Opt-in, two-way, one thread per tab. User docs: [discord-bridge](discord-bridge.md). - **Who can type into a tab.** Only `allowedUserIds`, in `guildId`, inside a thread the bridge created. Everything else is dropped unread. A reply is one printable line plus Enter (a lone digit while a permission or question is pending goes as the key alone, since the dialog acts on it), capped and rate limited, and typed with `fromUser: true`. The bridge's own startup hint is typed with `fromUser: false`. - **A Discord failure never reaches a session.** `notify()` never throws; a failed thread creation is returned by `threadFor` as a `DiscordResult`, not read back from shared state. - **`null` means the bridge is off.** A deduped automatic event answers `{ ok: true, deduped: true }`; a deliberate `codeman notify` is never deduped. A 200 from `discord-notify` carries the delivery result in `data.ok`, and `codeman notify` exits 1 when it is false. - **Threads belong to a parent channel.** `discord-bridge.json` is `{ parentChannelId, threads }`; `reload()` drops the map when the channel changes, and a creation that finishes after the change is discarded. - **Deletes come from the cleanup path** (`killMux` true), never from `session:deleted`, which a detach also fires. - **Files.** Received files come only from Discord's HTTPS hosts into `<tab folder>/.codeman-discord/` (bounded probe, refused if a symlink, self-ignoring). A sent file's real path must lie inside the tab's folder; both the folder and the file are probed with the bounded probe first. - **The whole last message.** A finished turn or an error posts the CLI's whole last message, not the screen: `WebServer.lastResponseFor` is the response viewer's reader (the body of `GET /api/sessions/:id/last-response`, registered on the route context by `registerSessionRoutes`), so the source is the same per-CLI transcript, or the pane split into turns where a CLI keeps none; a dialog and a shell keep the screen tail (its composer row and the status lines under it cut, never a `❯ 1.` option row). The read is bounded to 4 s and any failure or empty read falls back to the screen. It is split into at most 8 posts of 1,900 characters, a cut code fence closed and reopened, and Markdown tables become aligned code blocks (the separator test strips whitespace first: a regex over a run of spaces is quadratic, and the text is steerable by whatever the agent read). The repeat check is keyed on the text plus the turn it came from (the transcript stamp, else the last submit) and is recorded only after the first post succeeds, so a failed post is sent again and a later turn ending on the same words is not dropped. Only some CLIs raise a turn end (`/api/hook-event`: Claude's `stop` and `idle_prompt`, DeepSeek's `stop`), so the others reach this path through errors, a blocked respawn or a crash loop. - **Cost guards.** The alert tail is a function called only once a post will happen (no tmux exec while off), and `discord-notify` is a per-session token bucket (6 a minute, `429 RATE_LIMITED`).