Only CLAUDE.md conflicted: master restructured it into the short-rule + docs/architecture-invariants.md pointer layout while this PR was open. The response-viewer detail now lives in architecture-invariants, so the Claude turn-grouping and restored-placeholder rebind notes moved there. Changeset rewritten to record the measured effect on real transcripts.
83 KiB
Architecture invariants
Implementation detail extracted from CLAUDE.md so that file stays small enough to load into every session cheaply. Nothing here was rewritten: these are the original paragraphs, verbatim, including the version history and PR references that explain why each rule exists.
CLAUDE.md keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
Network binding and instance isolation
Default bind, and the non-loopback warning path
Default bind is loopback-only; non-loopback without a password starts but warns — since COD-29 (PR #107) the web server defaults to --host 127.0.0.1 (was 0.0.0.0). As of 0.9.0 binding a non-loopback host (--host/-H/CODEMAN_HOST) without CODEMAN_PASSWORD no longer refuses to start — it starts and prints a loud warning listing the fixes (set CODEMAN_PASSWORD, bind loopback + tunnel/tailscale serve, or --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge → terser note). Host classification is isLoopbackBindHost() in network-auth-policy.ts; the warn-vs-start logic is in server.ts start(); flags wired in cli.ts. ⚠️ Operational note: the production systemd unit runs node dist/index.js web --https with no --host, so it binds localhost only — reach it remotely via tailscale serve/tunnel to 127.0.0.1, or add Environment=CODEMAN_HOST=0.0.0.0 + Environment=CODEMAN_PASSWORD=… to ~/.config/systemd/user/codeman-web.service. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → 127.0.0.1) but NOT by a browser hitting the box's LAN IP. Auth user defaults to admin. Installer note (1.8.x, install.sh): interactive installs now PROMPT for the binding, defaulting to LAN (0.0.0.0) with a required password prompt (skipping the password needs an explicit confirm and prints a loud warning); non-interactive installs keep loopback unless CODEMAN_HOST is preset, and re-runs/updates preserve the EXISTING binding (read_existing_binding() parses the current systemd unit / launchd plist). The server binary's own default is unchanged. Full model: docs/security-architecture.md.
Instance isolation and the multi-instance attach danger
Instance isolation / multi-instance attach danger — data dir (~/.codeman) and tmux socket (tmux -L codeman) are PROCESS-WIDE and shared by every Codeman on the machine, derived from CODEMAN_INSTANCE via src/config/instance.ts (getDataDir()/dataPath()/DEFAULT_TMUX_SOCKET). ⚠️ A 2nd instance on the SAME socket discovers and attaches PTYs to the first instance's live sessions (tmux -L codeman attach-session …), resizing/mutating them — $HOME isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct CODEMAN_INSTANCE (scopes BOTH dir+socket: ~/.codeman-<name> + -L codeman-<name>), or set CODEMAN_TMUX_SOCKET + CODEMAN_DATA_DIR individually. CODEMAN_INSTANCE defaults to empty = the production layout (~/.codeman, -L codeman, port 3000), so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with scripts/run-beta.sh (CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000) — it never collides with prod's data dir/socket/port. Any new ~/.codeman/... path MUST go through dataPath(), never join(homedir(), '.codeman', …).
Session launch modes
External CLI modes (OpenCode, Codex, Gemini)
External CLI modes (OpenCode, Codex, Gemini): isExternalCliMode() in session.ts (mode === 'opencode' || 'codex' || 'gemini') 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). All three modes require tmux — no direct PTY fallback — because secrets are injected via tmux setenv (socket-scoped ${this.tmux()} setenv, never on the spawn command line): OpenCode gets OPENCODE_CONFIG_CONTENT etc., Codex gets OPENAI_API_KEY/CODEX_API_KEY/CODEX_HOME (setCodexEnvVars), Gemini gets GEMINI_API_KEY/GOOGLE_API_KEY/GOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI etc. (setGeminiEnvVars, all in tmux-manager.ts). Codex specifics: command built by buildCodexCommand() (--model, resume <id>, --dangerously-bypass-approvals-and-sandbox from the codexConfig payload / codexDangerouslyBypassApprovals app setting; renderMode is schema-coerced to 'hybrid', the only supported mode). Gemini specifics: command built by buildGeminiCommand() (--skip-trust always, --approval-mode <default|auto_edit|yolo|plan> defaulting to yolo for parity with Claude's --dangerously-skip-permissions, --model, --resume from the geminiConfig payload); availability via GET /api/gemini/status — session/quick-start routes fail with OPERATION_FAILED + install hint (npm install -g @google/gemini-cli) when missing. Codex AND Gemini export COLORTERM=truecolor + unset NO_COLOR (other modes unset COLORTERM); 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. Frontend: run-mode dropdown → runCodex()/runGemini() in session-ui.js ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary 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).
Remote sessions over SSH
Remote sessions (SSH): Sessions can run the agent inside a durable tmux -L codeman-remote new-session -A on a remote host so it survives the SSH drop (COD-104), and can also discover + attach to codeman-* sessions another Codeman launched there — attached (owned:false) sessions detach, never kill on tab close (COD-105). Shared/collaborative (COD-106): remote set-options are scoped per-session (never -g) and window-size latest lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. Auto-reconnect (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch remoteAutoReconnect, default ON); the pure pieces (backoff schedule, per-session reconnect state, decideReconnect eligibility) live in src/remote-reconnect.ts (tests: test/remote-auto-reconnect.test.ts), while tmux-manager.ts owns the live pane probe + timers. Owned sessions propagate kill-session to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe buildSshConnectionArgs() — every user field (-J jumpHost, -i identity, -o) is shellescaped; never hand-build an ssh line elsewhere. Full design: docs/remote-sessions.md.
Remote SSH cases
Remote SSH cases (COD-94/#145): cases can point at a remote host (~/.codeman/remote-hosts.json + remote-cases.json via src/remote-hosts.ts; CRUD under /api/cases — cases route file). A remote session launches a LOCAL tmux pane running ssh <host> that creates a durable REMOTE tmux session on a dedicated socket -L codeman-remote with name codeman-ssh-<id> — deliberately failing the remote Codeman's SAFE_MUX_NAME_PATTERN so a Codeman instance on the target host never adopts it; no -g global tmux options are set remotely. remotePath/identityFile are schema-guarded against shell injection (backticks/$ rejected — same approach as extraSshOptions); remote tmux availability is probed via checkRemoteTmuxAvailable() in quick-start (ssh args carry -o ConnectTimeout=10). Remote claude defaults to exec claude --dangerously-skip-permissions; 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.
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 five CLI backends runs INSIDE it. Like remote-SSH, it is a LOCATION OVERLAY on cases, never a sixth SessionMode (SessionMode is unchanged). Storage ~/.codeman/docker-hosts.json + docker-cases.json via src/docker-hosts.ts (direct mirror of remote-hosts.ts: readDockerHosts/readDockerCases, toSessionDocker, dockerDisplayPath, and the PURE builders buildDockerBaseArgs/buildDockerCreateArgs/containerApiUrl/hostGatewayAlias/dockerConfigHash). CRUD /api/docker-hosts + /api/cases/docker-link, plus one-click /api/cases/docker-quickcreate (Create New "Run in Docker" checkbox → case folder in CASES_DIR + auto-provisioned shared default host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case q-<name> host), and export/import (/api/docker-cases/:name/export, /api/docker-cases/import, GET/DELETE /api/docker-exports) — all in case-routes.ts. Run flows route through POST /api/quick-start like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). Launch model: exactly one long-lived container per case (codeman-case-<slug>, PID1 sleep infinity under --init); a LOCAL tmux pane runs docker exec -it into a durable in-container tmux on dedicated socket -L codeman-docker, session codeman-dkr-<id8> (deliberately fails SAFE_MUX_NAME_PATTERN so a Codeman running INSIDE the container never adopts it, exactly like remote's codeman-ssh-<id8>). Builders buildDockerLaunchCommand/buildDockerKillCommand in tmux-manager.ts (image-check → docker inspect||create → start → exec, all idempotent). The container is shared by all sessions of the case: buildDockerKillCommand kills ONLY that session's in-container tmux session, NEVER docker stop while siblings remain; docker rm -f happens only on case-delete (plus an instance-scoped boot reaper keyed on the codeman.instance label). Two-layer durability/resume (the central design point): (1) Codeman-PROCESS restart with the container still up → tmux new-session -A reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via claudeDockerPaneCommand() (tmux-manager.ts) — fresh launch claude --session-id <sessionId> || claude --resume <sessionId> (a duplicate --session-id exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume --resume <rid> || --session-id <sid> so a stale id never dead-panes (leading exec is stripped — an exec'd first branch could never fall back); codex resume <id> / gemini --resume keep appendResumeFlag. The resume id rides resumeSessionId through create/respawn options and persists on DockerCase.lastClaudeSessionId via persistDockerCaseClaudeSessionId() (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-/clear switches track; seeded back when resumeOnStart, default true); -A makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). Config drift (dockerConfigHash → codeman.confighash label): quick-start compares via checkDockerConfigDrift() and REFUSES a drifted launch with CONFLICT; the UI confirm calls POST /api/docker-cases/:name/recreate (refused while case sessions are live) which docker rm -fs so the next launch recreates with the new config — host config edits actually take effect. Workspace is a REAL host dir bind-mounted at the SAME absolute path (mirror, dst==src), so Session.workingDir = hostWorkspacePath keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; resolveMuxAttachCwd returns /tmp for docker (the local pane only runs docker exec). Creds arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (~/.claude/projects transcripts; codex sessions/ + history.jsonl for response-viewer/codex resume); everything else is SEEDED (RO mount, copied into container HOME once at launch via [ -e ] || cp; the container refreshes its own copy and never writes back): ~/.claude.json is merged through buildSeamlessClaudeConfig() (forces hasCompletedOnboarding + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus .claude/{.credentials.json,settings.json,stats-cache.json}, plus whole-dir seeds for ~/.gemini/~/.config/{gcloud,opencode} (resolveDockerClaudeArtifacts/resolveDockerCredentialArtifacts in docker-hosts.ts). Bind mounts are physically excluded from docker commit, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY --env OPENAI_API_KEY (no =value); the SEALED profile is mountCredentials:false + network:none. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. Hardening on every create: --cap-drop ALL, --security-opt no-new-privileges, --pids-limit, --memory==--memory-swap, non-root via --user <hostUid>:0 (Linux, GID 0 for writable HOME) / --userns=keep-id (podman rootless) / baked uid (Docker Desktop), --pull=never, --init. Base image codeman/agent:base is BUILT LOCALLY from docker/agent.Dockerfile (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, C.UTF-8 locale so tmux/Ink render real box-drawing glyphs; Codeman also sets LANG/LC_ALL at run time for containers built before that line) via scripts/build-agent-image.mjs OR auto-built on first use (1.4.1: ensureAgentBaseImage() in docker-hosts.ts; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, --pull=never stays absolute; build output streams over SSE docker:imageBuildStarted/imageBuildProgress/imageBuildComplete/imageBuildFailed, and quick-create returns imageBuilding:true while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (checkDockerTmuxAvailable), never a silent bare-exec fallback. Hooks + model: the workspace-scaffolding block DOES run for docker (writes .claude/settings.local.json + the CLAUDE.md scaffold into the real host dir), so modelOverride works via settings.local.json — it is a QuickStartSchema field applied for local AND docker quick-starts (updateCaseModel), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); effort/envOverrides/codexConfig/geminiConfig/openCodeConfig stay rejected. In-container hook curls hit containerApiUrl(process.env.CODEMAN_API_URL, engine) (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both host.docker.internal/host.containers.internal (DOCKER_HOST_GATEWAY_ALIASES in network-auth-policy.ts). ⚠️ On a loopback-only bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when CODEMAN_DOCKER_BRIDGE_HOOKS=1 — an opt-in SECOND listener on the docker bridge gateway (_startDockerBridgeHooksListener in server.ts; gateway auto-detected via detectDockerBridgeGateway, or set CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set CLAUDE_CODE_TMPDIR keeps claude launching regardless of workspace path. SessionState.docker/MuxSession.docker round-trip through recovery. Every docker IO path is IS_TEST_MODE (VITEST) no-op'd; the pure builders are unit-tested. Export/import (src/docker-export.ts): full-image (docker commit + save | gzip + workspace tar + manifest) or workspace-only → one portable ~/.codeman/docker-exports/<case>-<ts>.codeman-container.tgz; import validates per-member sha256, traversal-guards the workspace tar, docker loads + quarantine-retags the image (codeman/imported-<case>:<ts>, never overwriting a local tag); a saveImageToTar stream pipeline avoids truncation. GPU passthrough (gpus → --gpus, needs the NVIDIA container toolkit) and elastic disk (no --storage-opt cap, so container storage grows with data). SSE docker:exportComplete/exportFailed/importComplete (both registries). UI in session-ui.js: Create Case Docker tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short (docker) case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs w<n>-<case> via the shared _nextCaseSessionStartNumber() so all tabs follow one naming convention. Tests: test/docker-hosts.test.ts, test/docker-exec-options.test.ts, test/docker-export.test.ts, test/network-host-guard.test.ts.
Session data and lifecycle
Input delivery and WS resilience
Input: session.writeViaMux() for programmatic/curl input — tmux send-keys -l (literal) + send-keys Enter. Single-line only (fire-and-once). Interactive browser input goes through a durable exactly-once layer: each frame carries a stable clientId + monotonic per-session seq, persisted to localStorage until the server ACKs ({t:'ia',seq} over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. 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).
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 (statusLine telemetry, opt-in showPlanUsageLimits, default OFF): 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}). Codeman injects its OWN statusLine exporter (generateStatusLineCommand() in hooks-config.ts, identified by the /api/status-telemetry marker — it only ever adds/updates/removes a statusLine that is ours, never a user's hand-authored one) that POSTs the 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 (so injecting our statusLine doesn't blank the in-terminal footer). plan-usage-latest.ts holds the process-wide last value, replayed in the SSE init snapshot (getLightState) so the header chip (#planUsageChip, toggled by showPlanUsageLimits in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. Distinct from auto-resume (which reacts to the limit message; this proactively shows the live %). Design: docs/usage-limits-display-plan.md. Tests: test/usage-telemetry.test.ts.
Cron jobs
Cron (cron-style CronJobs): saved, named jobs with a recurring schedule (once/interval/daily/weekly), enable/disable, Run Now, next-run calc, and per-job run history (CronJobRun). ⚠️ Distinct from the legacy ScheduledRun (/api/scheduled, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the Scheduled* names, the recurring-job feature is Cron*. CronService (src/cron/cron-service.ts) owns CRUD + the 30s background due-tick (tickDueJobs, registered via cleanup.setInterval in server.ts; init() recomputes nextRunAt on boot) and reuses the existing session layer (create → addSession → setupSessionListeners → startInteractive/startShell → prompt via writeViaMux/write) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in cron-time.ts (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = lastDueKey (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. once jobs self-disable after firing (completedOnce). Persisted via AppState.cronJobs/cronJobRuns (StateStore accessors). Routes /api/cron/jobs* + /api/cron/runs (cron-routes.ts, CronPort); schema CronJobSchema (cross-field superRefine; the .partial() update schema does NOT re-run it); SSE cron:*. Frontend cron-ui.js (#cronModal). Claude/shell/opencode/codex/gemini 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 Claude transcript files into one deduped list (pure core in src/services/unified-session-service.ts). Transcript rows are keyed by conversation UUID and folded into their owning session via a claudeSessionId → Codeman id alias map (resumed//clear-respawned sessions must not appear twice); 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).
Full-scrollback replay
Full-scrollback replay (COD-164/#148): 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). Only the FIRST buffer load after a page load requests full=1 (one-shot _initialFullBufferLoad flag in app.js); tab switches keep the cheap ?tail= visible-frame path. Tests: test/tmux-capture-full-history.test.ts, test/tmux-scrollback-eol.test.ts.
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.
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.
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.
Filesystem path picker
Filesystem path picker (Link Existing "Browse" button + the extended mobile keyboard's 📁 Path key): a lazy one-directory-at-a-time browser over GET /api/filesystem/browse, with GET /api/filesystem/preview serving the tapped file. It starts at the active session's working directory (falling back to /mnt/d), hides dot entries, and inserts the chosen path without Enter so the prompt is not submitted. The companion ⌫ All key clears only the current unsent prompt buffer and must never emit the agent's /clear command.
⚠️ This is a second file-serving surface, so it carries the same confinement burden as Attachments and does not inherit it automatically. Traversal is allowlisted to Home, CASES_DIR, /mnt/d, or extra roots explicitly configured via CODEMAN_FILE_PICKER_ROOTS; sensitive trees are blocked and symlink escapes are rejected after realpath resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. preview reuses the shared conversion cache and the global document-conversion-limiter, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert text/plain (never text/html, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
⚠️ Ownership scoping is also not inherited, and both endpoints must do it themselves. Two separate holes shipped in the original version and are now regression-guarded in test/routes/file-routes.test.ts:
- The optional
sessionIdparam adds that session'sworkingDiras a "Current Folder" root. It is looked up directly offctx.sessions/ctx.storerather than throughfindSessionOrFail, so thecanAccessOwnedcheck has to be written out by hand. Without it a multi-user caller pins another user's working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists. HomeandCASES_DIRwere unconditional roots. Per-user spaces live at<USER_SPACES_DIR>/<username>, which is insidehomedir(), so aHomeroot alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets onlyMy Space(their ownuserSpacePath) plus anything inCODEMAN_FILE_PICKER_ROOTS;/mnt/dis dropped too, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins and single-user mode keep the host-wide set unchanged.
The general rule: any new endpoint that turns a caller-supplied sessionId into a filesystem path is an ownership boundary, whether or not it goes through findSessionOrFail.
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.
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.
Self-update
Self-update (App Settings → 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.
Web tabs
Web tabs (saved dashboard URLs rendered as tabs beside agent sessions; user guide docs/web-tabs.md). A webview is NOT a sixth SessionMode: 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'), import('/chunk.js')); it is the only exemption decided by a request-supplied header, so it is fenced to safe methods on non-/api, non-/ws, non-/q paths. Without that fence a page could present a webview Referer and skip auth on /api. test/webview-auth-exemption.test.ts pins every edge.
Sandbox default. The iframe carries allow-scripts allow-forms allow-popups allow-downloads allow-modals and gains allow-same-origin ONLY when the dashboard is explicitly trusted. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In both modes, Authorization and the codeman_session cookie are stripped before the upstream request (buildUpstreamRequestHeaders), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand CODEMAN_PASSWORD to the dashboard.
Two things a sandboxed frame breaks that are invisible to curl. Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
- Root-absolute URLs built at runtime.
<base href>only governs URLs the HTML parser resolves;fetch('/api/data')bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. TheReferer-keyed 404 fallback deliberately refuses/api,/ws,/q(widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix isruntimeUrlShim(): a small script injected right after<base>that patchesfetch,XMLHttpRequest.open,WebSocketandEventSourceto rebase 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). - CORS on same-host requests. An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its
fetch/XHR are CORS-checked and its preflights carryOrigin: null. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaquenet::ERR_FAILED.buildProxyCorsHeaders()echoes the origin (omittingallow-credentialsfornull, which browsers reject in combination), upstreamaccess-control-*headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️registerSecurityHeadersanswers EVERYOPTIONSwith a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit must exempt a valid webview capability or every preflight fails.curlcannot reproduce any of this because curl does not enforce CORS.
Rewrites, each load-bearing (pure + unit-tested in src/web/webview-proxy.ts): drop x-frame-options and the CSP frame-ancestors directive (the point of the proxy); drop content-encoding/content-length because undici's fetch already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite Location for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase Set-Cookie Path onto the prefix and drop Domain; inject <base href> and rebase root-absolute src/href/action. resolveUpstreamUrl() returns null on anything escaping the upstream origin.
Fastify specifics. The proxy lives in an encapsulated plugin scope with removeAllContentTypeParsers() + a '*' pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps text/plain RAW, which was a real CSRF hole once). One app.route({ method:'GET', handler, wsHandler }) serves both HTTP and the WebSocket upgrade; HEAD must NOT be declared on the sibling route because exposeHeadRoutes already derives it and the duplicate is a startup error. ⚠️ Every exit path in the proxy handler RETURNS reply.send(...): the handler is async, and a bare return after reply.send(stream) resolves the handler promise to undefined before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off setNotFoundHandler, so real Codeman routes always win.
Frontend (src/web/public/webview-tabs.js, load order 12.5): web tabs render into #sessionTabs carrying data-webview-id instead of data-id, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ _renderSessionTabsImmediate()'s canIncremental check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (0 === 0), where opening a dashboard would never draw its tab. ⚠️ isActive for a session tab is id === activeSessionId && !activeWebviewId: activeSessionId stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past maxLiveFrames) so tab switching never reloads a dashboard.
Pre-existing bug fixed alongside: .toolbar has backdrop-filter, which makes it a stacking context and TRAPS .run-mode-menu's z-index: 1000 inside it. With the toolbar itself at z-index: auto, .welcome-overlay (z-index 10, inside <main>) painted over the popped-up Run menu, making every item in it unclickable whenever no session was open. .toolbar now carries z-index: 20 (must stay below .modal's 1000).
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.
Frontend
Command palette and shortcut registry
Command palette + shortcut registry (COD-151/153/157/192, #146): Ctrl/Cmd/Alt+K opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by GET /api/sessions/unified); the quick-start case <select> is fronted by a searchable picker (buildCasePickerOptions/formatCasePickerLabel — remote cases render name @ hostId). Shortcuts live in a rebindable registry (DEFAULT_SHORTCUTS/getShortcutRegistry()/matchesShortcutEvent() in app.js; overrides persist under settings.shortcutOverrides via saveAppSettingsToStorage); App Settings → Shortcuts renders capture/disable rows; Ctrl+? opens the registry-driven overlay (footer links to the full #helpModal reference). ⚠️ Palette-chord keys must ALSO be swallowed in attachCustomKeyEventHandler (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ saveAppSettings() rebuilds settings from the DOM — keys edited elsewhere (shortcutOverrides, showTokenCount, showCost) need explicit _prev carry-over.
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 → Display → Header Displays ("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 → Display → 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.
⚠️ Claude transcripts are grouped at real human-turn boundaries, not per JSONL row. 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, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. 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. 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 → Display → Header Displays → 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 → Display → Header Displays); cron jobs themselves are unaffected.
Gesture control: the setting
Gesture control (the camera hand-tracking overlay) is opt-in, default OFF, under App Settings → Display → Input (gestureControlEnabled). CODEMAN_GESTURE=1 makes the feature available on the instance (CSP widening + /gesture/ assets) and sets window.__codemanGestureAvailable (the Input section only shows when set); the overlay bundle is injected by renderIndexHtml only when the setting is enabled, so that method is async and reads settings.json via readSettings(true) — the true forces a fresh read (bypassing the 2s _settingsCache), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
Gesture control: the source package
Gesture-control source lives in-repo at packages/gesture-control/ (workspace package codeman-gesture-control, was the standalone Ark0N/codeman-gesture-control repo). The transport-agnostic core is src/gesture/* (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); src/codeman/entry.ts is the Codeman consumer that maps grab/drag/drop onto real .session-tab/toolbar buttons and is the bundle entry. Edit there, then run npm run build:gesture (scripts/build-gesture-bundle.mjs → esbuild bundles entry.ts, MediaPipe JS included, into src/web/public/gesture/gesture-codeman.js) and commit the regenerated bundle — the committed bundle is what dev/tsx serves (no bundler at runtime), and scripts/build.mjs reruns the same step so prod always reflects current source. The MediaPipe wasm + model are NOT bundled — loaded at runtime from same-origin /gesture/wasm + /gesture/gesture_recognizer.task, fetched by scripts/fetch-gesture-assets.mjs (gitignored, see Gotchas). entry.ts mounts window.__codemanGesture = new GestureBridge() idempotently at module-eval. A standalone vite playground (npm run dev in the package — fake tabs, no Codeman) lets you iterate on gesture feel in isolation. ⚠️ Keep MP_VERSION in fetch-gesture-assets.mjs in sync with @mediapipe/tasks-vision in packages/gesture-control/package.json.
Theme skins
Theme skins (App Settings → Display): 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. 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 → Display → Branding & Language): 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.
Security layers
Layer-by-layer detail
| Layer | Details |
|---|---|
| Auth | Optional HTTP Basic via CODEMAN_USERNAME (defaults to admin) / CODEMAN_PASSWORD env vars. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts) |
| Network bind | Defaults to 127.0.0.1 (loopback). A non-loopback bind (--host/CODEMAN_HOST) without CODEMAN_PASSWORD starts but warns loudly (0.9.0; was fail-closed in COD-29/#107). --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 acknowledges the warning. Classifier: network-auth-policy.ts |
| Host guard | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, *.ts.net/*.trycloudflare.com/*.cfargotunnel.com, the active managed tunnel, and CODEMAN_ALLOWED_HOSTS. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix. registerHostGuard in server.ts; policy in network-auth-policy.ts (buildHostPolicy/isAllowedRequestHost/isAllowedRequestOrigin) |
| CSRF / Origin | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). A missing Origin is allowed so curl/CLI and Claude Code hooks keep working. The global body parser keeps text/plain RAW (no auto-JSON-parse, which had enabled simple-request CSRF); /api/crash-diag self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in ws-routes.ts. Added in c669518 (closes 2026-06-09 review CRITICALs) |
| QR Auth | Single-use 6-char tokens (60s TTL) for tunnel login. See docs/qr-auth-plan.md |
| Sessions | 24h cookie (codeman_session), auto-extend, device context audit |
| Rate limit | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
| Hook bypass | /api/hook-event (and /api/status-telemetry, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (CODEMAN_PASSWORD set), the loopback bypass requires the per-instance X-Codeman-Hook-Secret header unconditionally — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/tailscale serve/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via $CODEMAN_HOOK_SECRET_FILE (session env, config/hook-secret.ts); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable refuses without CODEMAN_PASSWORD unless exposure is acknowledged — via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 (env, COD-55) or the per-request acknowledgeUnauthTunnel:true action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
| Env vars | CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from CODEMAN_DOCKER_BRIDGE_HOST or auto-detect) |
| Validation | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*) |
| Headers | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
Performance and limits
Buffers, uploads, and terminal history
Target: 20 sessions, 50 agent windows at 60fps. Limits in src/config/: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Terminal history (src/config/terminal-history.ts, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env CODEMAN_MAX_TERMINAL_BUFFER/CODEMAN_TRIM_TERMINAL_TO; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable BufferAccumulator trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (DEFAULT_SCROLLBACK in constants.js — 100k/tab is a mobile-memory hazard). Settings keys terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes are schema-validated but inert (only tmuxHistoryLimit is wired live); buffer-limits.ts re-exports the defaults. Text/message limits are env-overridable too (CODEMAN_MAX_TEXT_OUTPUT/CODEMAN_TRIM_TEXT_TO/CODEMAN_MAX_MESSAGES). Image upload (image-input.js / config/buffer-limits.ts): up to _maxBatchImages 20 images/batch (bounded concurrency 3), per-file MAX_PASTE_IMAGE_BYTES 50MB (env CODEMAN_MAX_PASTE_IMAGE_BYTES); the mobile camera-roll picker auto-downscales to fit before upload. HEIC paste uploads (#151): converted server-side to JPEG in a worker_threads worker (web/heic-jpeg-worker.ts, resourceLimits + 30s timeout) gated by runWithConversionLimit(); detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: heic-decode + jpeg-js. Use LRUMap for bounded caches, StaleExpirationMap for TTL cleanup. Anti-flicker pipeline: docs/terminal-anti-flicker.md.
Local packages and build artifacts
xterm-zerolag-input is single-source
xterm-zerolag-input is single-source — edit the package, then rebuild the bundle — the local-echo overlay source lives ONLY in packages/xterm-zerolag-input/src/ (zerolag-input-addon.ts; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended window.LocalEchoOverlay aliases) into the gitignored src/web/public/vendor/xterm-zerolag-input.js by scripts/postinstall.js (for dev/tsx) and into dist/.../vendor/ by scripts/build.mjs (the xterm-zerolag-input esbuild step, for prod). app.js only consumes it via new LocalEchoOverlay(terminal) — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (npm install reruns postinstall; npm run build for prod); never hand-edit app.js for overlay behavior or commit the gitignored vendor bundle. 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.
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).