Files
Codeman/CLAUDE.md
T
Codeman maintainer d41f28bc14 docs(docker): warn that a plain agent-image rebuild keeps stale CLIs
The CLIs live in one `RUN npm install -g` layer, so rebuilding without
--no-cache re-uses it and freezes them at the versions the image was FIRST
built with. Editing the Dockerfile does not help when the edit lands below
that line: the npm layer stays cached and only the new step runs.

That is not hypothetical. Adding the Antigravity step (which appends below
the npm line) produced a "successful" rebuild that silently kept a stale
@openai/codex@0.144.6 whose aliased platform binary had never installed, so
every codex docker case died with "Missing optional dependency
@openai/codex-linux-x64" while the build reported success. A --no-cache
rebuild fixed codex and also un-froze claude, gemini and opencode.

Documents the failure, makes --no-cache the recommended invocation in both
the guide and the CLAUDE.md quick-reference row, and adds a verify command
that actually executes each CLI, since a zero exit code only proves the
layers ran.

No changeset: docs-only, rides the next release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:02:28 +02:00

76 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Deep implementation detail lives in docs/architecture-invariants.md. This file holds the rules that prevent mistakes; that file holds the mechanisms, file inventories, and the history behind each rule. Pointers below are written as → architecture-invariants#anchor. When the goal is raw throughput, docs/SPEEDRUN.md is the fast-execution protocol (it removes ceremony, never the safety rules here).

This file is in .prettierignore on purpose. Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (agent-*.jsonl became agent-\_.jsonl, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run prettier --write on it.

Repo root is kept short on purpose (the README sits below the file listing on GitHub). Config lives in config/ (eslint.config.js, knip.json, the vitest configs), Prettier's config is the "prettier" key in package.json, and SECURITY.md is under .github/. Root-only files are the ones tools genuinely require there: CLAUDE.md + AGENTS.md (loaded from the root by Claude Code / Codex), CHANGELOG.md (changesets writes it next to package.json), tsconfig.json, .editorconfig, .nvmrc/.npmrc, .prettierignore (resolved relative to cwd), LICENSE (GitHub detection) and install.sh (its raw URL is the published install one-liner). Don't relocate those.

Quick Reference

Task Command
Dev server npm run dev (or npx tsx src/index.ts web)
Type check tsc --noEmit
Lint npm run lint (fix: npm run lint:fix)
Format npm run format (check: npm run format:check)
Single test npm test -- test/<file>.test.ts (or npx vitest run --config config/vitest.config.ts test/<file>.test.ts) — ⚠ never run bare npm test, see Testing section
Build npm run build (esbuild via scripts/build.mjs, NOT tsc — tsc --noEmit is type-check only)
Production npm run build && systemctl --user restart codeman-web

CRITICAL: Session Safety

You may be running inside a Codeman-managed tmux session. Before killing ANY tmux or Claude process:

  1. Check: echo $CODEMAN_MUX - if 1, you're in a managed session
  2. NEVER run tmux kill-session, pkill tmux, or pkill claude without confirming
  3. Use the web UI or ./scripts/tmux-manager.sh instead of direct kill commands

The working tree is shared with other agent sessions. Several Codeman sessions run against THIS one checkout, so another session can git checkout a different branch, or leave half-finished untracked files, while you are mid-task.

  • Always git branch --show-current immediately before committing. Observed 2026-07-27: another session ran git checkout -b feat/web-tabs, a commit silently landed there instead of master, and the follow-up git push origin master cheerfully reported "Everything up-to-date".
  • To land a commit on master without switching branches (which would yank the tree out from under the other session): git push origin HEAD:master then git branch -f master HEAD. Never git checkout master to "fix" it.
  • Never git add -A/git add . — stage explicit paths. A sweep will pick up another session's WIP.
  • Another session's broken WIP can block npm run build, since tsc is the first step and the build gates on it. That is not your bug to fix. ⚠️ tsc still EMITS on type errors, so a failed npm run build leaves a rebuilt dist/index.js compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked tsc, run the asset stage of scripts/build.mjs (everything after the tsc/chmod lines is independent of it).

CRITICAL: Always Test Before Deploying

NEVER COM without verifying your changes actually work. For every fix:

  1. Backend changes: Hit the API endpoint with curl and verify the response
  2. Frontend changes: Use Playwright to load the page and assert the UI renders correctly. Use waitUntil: 'domcontentloaded' (not networkidle — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
  3. Only after verification passes, proceed with COM

The production server caches static files for 1 year, immutable (maxAge: '1y' in server.ts). To avoid stale frontend after a deploy, renderIndexHtml runs cacheBustAssets(html) — it appends ?v=<mtime> to every same-origin .js/.css reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because index.html is served no-cache, a normal reload now picks up edited modules/styles — no hard refresh needed (the gesture bundle is injected separately with its own ?v=). If you add an asset referenced by an absolute URL or from JS rather than a <script>/<link> tag, it won't be auto-busted.

COM Shorthand (Deployment)

Uses Semantic Versioning (MAJOR.MINOR.PATCH) via @changesets/cli. What SemVer actually covers (the CLI, documented env vars, and the HTTP/SSE API under /api/v1: endpoint paths, response envelope, errorCode values and SSE event names are public/stable; on-disk state, internal TS modules, and experimental features are internal/unstable) is defined in docs/versioning-policy.md. Third-party integration surfaces are documented in docs/extending-codeman.md. Security reporting + known limitations live in .github/SECURITY.md.

When user says "COM":

  1. Determine bump type: COM = patch (default), COM minor = minor, COM major = major

  2. Create a changeset file (no interactive prompts). Write a .md file in .changeset/ with a random filename:

    cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
    ---
    "aicodeman": patch
    ---
    
    Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
    CHANGESET
    

    Replace patch with minor or major as needed. Include "xterm-zerolag-input": patch on a separate line if that package changed too.

  3. Consume the changeset: npm run version-packages (auto-bumps package.json files, updates CHANGELOG.md, runs npm install --package-lock-only, and verifies lockfile sync via scripts/check-lockfile-sync.mjs — all in one command; never hand-edit CHANGELOG.md or package-lock.json versions)

  4. Sync CLAUDE.md version: Update the **Version** line below to match the new version from package.json

  5. Commit and deploy: verify the branch first (git branch --show-current), then stage EXPLICIT paths — never git add -A, which has swept another session's WIP into a release. git status --short and account for every line before committing: git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web

  6. Wait for CI: after git push, TWO workflows fire per master push — CI and Release (the npm publish + GitHub release). List both runs for the pushed commit with gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName and watch EACH with gh run watch <id> --exit-status. Confirm both pass before considering the release done (gh run list -L 1 returns only one of the two).

CI runs npm run check:lockfile on every push/PR, so lockfile drift fails the build even if the version-packages script is bypassed.

Version: 1.11.2 (must match package.json)

Project Overview

Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.

Tech Stack: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), and Antigravity (agy, Google) CLIs via pluggable CLI resolvers (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity').

TypeScript Strictness (see tsconfig.json): noUnusedLocals, noUnusedParameters, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch, allowUnreachableCode: false, allowUnusedLabels: false.

Requirements: Node.js 22+, Claude CLI, tmux

Git: Main branch is master. SSH session chooser: sc (interactive), sc 2 (quick attach), sc -l (list).

Additional Commands

npm run dev = dev server. Default port: 3000 (override with --port or the CODEMAN_PORT env var). To run this beta isolated alongside a prod Codeman, use scripts/run-beta.sh (sets CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000). Commands not in Quick Reference:

Task Command
Dev with TLS npx tsx src/index.ts web --https
Override window title hostname npx tsx src/index.ts web --title-hostname <name> (default: os.hostname() — codeman:<name> is used for tab title, title-flash, and OS desktop notification prefix)
Bind a non-loopback host npx tsx src/index.ts web --host 0.0.0.0 (or -H; env CODEMAN_HOST; default 127.0.0.1). Without CODEMAN_PASSWORD it starts but warns loudly — see Common Gotchas + docs/security-architecture.md
Continuous typecheck tsc --noEmit --watch
Watch-mode test npm run test:watch -- test/<file>.test.ts (always pass a file — bare watch includes the browser suites)
Test coverage npm run test:coverage
Dead-code sweep npm run knip (config in config/knip.json, passed via --config)
Rebuild gesture overlay npm run build:gesture (esbuild packages/gesture-control/src/codeman/entry.ts → src/web/public/gesture/gesture-codeman.js; commit the result)
Build the docker agent image node scripts/build-agent-image.mjs --no-cache (builds codeman/agent:base from docker/agent.Dockerfile; prerequisite for Docker cases; --engine/--image). ⚠ Always --no-cache — a plain rebuild re-uses the cached npm install -g layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See docs/docker-cases.md
Gesture playground npm run dev in packages/gesture-control/ (standalone vite demo, fake tabs)
Check public-asset formatting npm run check:public-assets (prettier-checks src/web/public/** text assets; scripts/check-public-assets.mjs)
Frontend JS syntax check npm run check:frontend-syntax (scripts/check-frontend-syntax.mjs; runs in CI)
CI-equivalent test sweep npm run test:ci (full suite minus browser/perf — see Testing)
Production start npm run start
Production logs journalctl --user -u codeman-web -f

CI: .github/workflows/ci.yml (push to master/main + PRs, Node 22) runs two jobs: (1) check:lockfile, typecheck, lint, check:frontend-syntax, format:check, then a server boot smoke test (tsx src/index.ts web --port 3151 must answer /api/status within 30s); (2) the unit/integration test suite via npm run test:ci (config/vitest.ci.config.ts — excludes the browser-driven test/mobile/** suite, perf-* benchmarks, and 3 Playwright tests). Tests are tmux-safe in CI: TmuxManager no-ops all shell commands under VITEST (see Testing).

Code style: Prettier (singleQuote: true, printWidth: 120, trailingComma: "es5") — config lives in the "prettier" key of package.json, not a .prettierrc (keeps the repo root short; editors read it natively). .prettierignore stays at the root because Prettier resolves it relative to cwd. ESLint flat config (config/eslint.config.js) allows no-console, warns on @typescript-eslint/no-explicit-any. Ignores: app.js, scripts/**/*.mjs, src/web/public/vendor/**, scripts/remotion/**.

Prettier scope is deliberately narrow. npm run format globs only src/**/*.ts and src/web/public/**, and .prettierignore then exempts most of src/web/public/*.js (app.js, styles.css, index.html, and 14 hand-formatted modules) plus CLAUDE.md. Those files are hand-formatted by design; npm run check:public-assets and check:frontend-syntax are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.

Common Gotchas

  • Single-line prompts only — writeViaMux() sends text+Enter separately; multi-line breaks Ink
  • ESM only — Never require(), use await import(). tsx masks CJS/ESM issues in dev but production breaks
  • Package ≠ product name — npm: aicodeman, product: Codeman. Release renames tags accordingly. Both aicodeman and codeman bin aliases are installed (package.json bin)
  • Global regex lastIndex — Shared g-flag patterns in loops must reset lastIndex = 0 first, or use the execPattern() helper in utils/regex-patterns.ts (resets automatically)
  • envOverrides flow CLAUDE_CODE_* / OPENCODE_* / CODEX_* / GEMINI_* / GOOGLE_* / ANTIGRAVITY_* env vars — Set via POST /api/sessions { envOverrides }, stored on Session._envOverrides, exported by tmux-manager.buildEnvExports() at spawn time, persisted in SessionState.envOverrides. Do NOT write these to <case>/.claude/settings.local.json — that's the old path and creates UI/disk drift. (GOOGLE_* is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.)
  • Effort is NOT an env var — never carry effort as CLAUDE_CODE_EFFORT_LEVEL: the env var hard-locks effort and blocks in-session /effort switching (incl. ultracode). It flows as the dedicated effort payload field → Session._effort → claude --effort <level> for regular levels incl. max (the settings effortLevel key is enum(["low","medium","high","xhigh"]).catch(undefined) — max gets SILENTLY dropped there), or claude --settings '{"ultracode":true}' for ultracode (rejected by --effort). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in applyEnvOverrides(). See buildEffortCliArgs() in session-cli-builder.ts, tests in test/effort-injection.test.ts
  • Model choice flows via settings.local.json, NOT --model or env — the App Settings Claude Model picker (claudeModel in settings.json) is read by session-ui.js at session create (wins over the legacy 1M-Opus toggles opusContext1m/opusContext1mEnabled), sent as the modelOverride payload field, and updateCaseModel() (hooks-config.ts) writes/deletes the model key in <case>/.claude/settings.local.json. This is the intended exception to the envOverrides rule above: model legitimately lives in settings.local.json (a soft default — in-session /model still works); env vars do not
  • Multi-CLI prefix discipline — env-var prefix is CLI-specific (CLAUDE_CODE_* vs OPENCODE_* vs CODEX_* vs GEMINI_* vs ANTIGRAVITY_*) and the ALLOWED_ENV_PREFIXES allowlist in schemas.ts enforces this. Gemini additionally allowlists the broad GOOGLE_* namespace (intentional: Vertex AI auth needs GOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: docs/opencode-integration.md
  • Zod .optional() rejects null — accepts undefined only. When the frontend builds a request body with JSON.stringify, an explicit null field is preserved on the wire and fails validation with INVALID_INPUT. Convert null → undefined before stringifying (e.g. field: value ?? undefined), or declare the schema .nullish(). This has caused real shipped bugs twice
  • xterm-zerolag-input is single-source — the local-echo overlay source lives ONLY in packages/xterm-zerolag-input/src/, and is bundled into the gitignored src/web/public/vendor/xterm-zerolag-input.js (dev, by scripts/postinstall.js) and dist/.../vendor/ (prod, by scripts/build.mjs). app.js only consumes it via new LocalEchoOverlay(terminal); there is no inline copy. So: change the package source, then rerun the bundle step (npm install for dev, npm run build for prod). Never hand-edit app.js for overlay behavior, and never commit the gitignored vendor bundle. Always test on mobile after touching it. → architecture-invariants#xterm-zerolag-input-is-single-source, docs/local-echo-overlay-plan.md
  • Default bind is loopback-only; non-loopback without a password starts but warns — the server defaults to --host 127.0.0.1. Binding non-loopback (--host/-H/CODEMAN_HOST) without CODEMAN_PASSWORD starts anyway but prints a loud warning; --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 acknowledges it. ⚠️ The production systemd unit passes no --host, so prod binds localhost only: reach it via tailscale serve/tunnel to 127.0.0.1. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. install.sh is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → architecture-invariants#default-bind-and-the-non-loopback-warning-path, docs/security-architecture.md
  • Instance isolation / multi-instance attach danger — the 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. ⚠️ A 2nd instance on the SAME socket discovers and attaches PTYs to the first instance's live sessions, resizing and mutating them. $HOME isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct CODEMAN_INSTANCE (scopes dir + socket together), or set CODEMAN_TMUX_SOCKET + CODEMAN_DATA_DIR individually; scripts/run-beta.sh does this for a beta alongside prod. Any new ~/.codeman/... path MUST go through dataPath(), never join(homedir(), '.codeman', …). → architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger
  • node-pty's macOS spawn-helper ships without +x (issues #6, #204): node-pty@1.1.0 publishes prebuilds/darwin-<arch>/spawn-helper as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with Error: posix_spawnp failed. Linux can never reproduce it: spawn-helper is an OS=="mac" gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in prebuilds/<platform>-<arch>/, not just build/Release/, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes prebuilds/ before compiling): npm run fix:node-pty chmods every helper then proves it by really opening a PTY. spawnPtyWithHelperRepair() (utils/node-pty-repair.ts) wraps every pty.spawn() in session.ts and self-heals a broken install on the first failure. → architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable
  • Headless screenshots: deviceScaleFactor MUST be 1, and write unique filenames — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still reporting nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; scripts/capture-real-overview.mjs mints a timestamped filename per run. Seed the per-device localStorage keys (codeman:skin, codeman-font-size, codeman-app-settings) so the capture matches a real device. → architecture-invariants#headless-screenshot-capture

Import conventions: Utils from ./utils, types from ./types (barrel), config from specific ./config/* files.

Architecture

Core Files (by domain)

Domain Key files Notes
Entry src/index.ts, src/cli.ts
Session src/session.ts ★, session-manager, session-auto-ops, session-cli-builder, session-task-cache, session-order (pure), session-pty-exit-breaker, usage-limit-patterns, usage-telemetry; src/services/unified-session-service.ts Pure/unit-tested helpers are split out of session.ts on purpose
Mux src/mux-interface.ts, src/mux-factory.ts, src/tmux-manager.ts ★
Respawn src/respawn-controller.ts ★ + 4 helpers (-adaptive-timing, -health, -metrics, -patterns) Read docs/respawn-state-machine.md first
Ralph src/ralph-tracker.ts ★, src/ralph-loop.ts + 5 helpers (-config, -fix-plan-watcher, -plan-tracker, -stall-detector, -status-parser) Read docs/ralph-wiggum-guide.md first
Orchestrator src/orchestrator-loop.ts, -planner, -verifier Read docs/orchestrator-loop-architecture.md first
Cron src/cron/cron-service.ts, cron-time.ts (pure next-run math), cron-input.ts Read docs/cron-discovery.md first. Distinct from legacy ScheduledRun (/api/scheduled)
Agents src/subagent-watcher.ts ★, team-watcher, bash-tool-parser, transcript-watcher, workflow-run-watcher workflow-run-watcher is STANDALONE and never touches subagent-watcher
AI src/ai-checker-base.ts, ai-idle-checker.ts, ai-plan-checker.ts
Tasks src/task.ts, task-queue.ts, task-tracker.ts
State src/state-store.ts, run-summary.ts, session-lifecycle-log.ts
Infra src/hooks-config.ts, push-store, tunnel-manager, image-watcher, file-stream-manager, remote-hosts + remote-reconnect (pure), docker-hosts + docker-export Remote/docker case overlays; see Key Patterns
Web tabs src/webview-store.ts, webview-capabilities.ts, src/web/webview-proxy.ts (pure), src/web/routes/webview-routes.ts Dashboard URLs as tabs; NOT a SessionMode
Search src/search-service.ts Pure in-memory core for GET /api/search
Attachments src/attachment-registry.ts, attachment-magic, generated-artifact-attachments, session-attachment-history, document-preview-cache, document-thumbnailer, document-conversion-limiter, config/attachment-guard See Key Patterns
Plan src/plan-orchestrator.ts, src/prompts/*.ts, src/templates/ (claude-md.ts + case-template.md) templates/ holds the CLAUDE.md scaffold generated into new cases
Web src/web/server.ts ★, sse-events.ts, routes/*.ts (20 modules + barrel; session-routes.ts ★), route-helpers.ts, ports/*.ts, middleware/auth.ts, schemas.ts, self-update.ts, plan-usage-latest.ts, ws-connection-registry.ts, heic-jpeg-converter.ts + heic-jpeg-worker.ts
Frontend src/web/public/app.js (~5K lines, core) + 25 modules + sw.js See Frontend section for the load order, which is authoritative
Types src/types/index.ts (barrel) → 20 domain files; also src/types.ts root re-export See @fileoverview in index.ts

★ = Large, central file (>50KB) — read its @fileoverview first. All files have @fileoverview JSDoc — read that before diving in. Discovery aid: grep -l '@fileoverview' src/web/routes/*.ts lists all route modules; same grep works for src/types/, src/web/public/*.js.

Local packages: packages/xterm-zerolag-input/ (local echo overlay, single-source, see Gotchas). packages/gesture-control/ (codeman-gesture-control, hand-tracking overlay source, built via npm run build:gesture).

Config: src/config/ — 17 files, no barrel (index.ts) exists; import from the specific file.

Utilities: src/utils/ — re-exported via index. Key: CleanupManager, LRUMap (⚠ NOT in the barrel — import from ./utils/lru-map.js directly), StaleExpirationMap, BufferAccumulator, stripAnsi, Debouncer, KeyedDebouncer. Also: claude-cli-resolver/opencode-cli-resolver/codex-cli-resolver/gemini-cli-resolver (CLI path resolution), string-similarity (fuzzy matching), regex-patterns (ANSI/token/spinner patterns), assertNever (exhaustive checks), token-validation (auth tokens), nice-wrapper (process priority).

Data Flow

  1. Session spawns claude --dangerously-skip-permissions via node-pty
  2. PTY output buffered, ANSI stripped, parsed for JSON messages
  3. WebServer broadcasts to SSE clients at /api/events
  4. State persists to ~/.codeman/state.json via StateStore

Key Patterns

Input: session.writeViaMux() for programmatic/curl input via tmux send-keys -l + send-keys Enter, single-line only. Interactive browser input goes through a durable exactly-once layer: a stable clientId + monotonic per-session seq persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. ws-connection-registry.ts supersedes only same-TAB reconnects, so two tabs on one session coexist. → architecture-invariants#input-delivery-and-ws-resilience

Idle detection: Multi-layer (completion message → AI check → output silence → token stability). See docs/respawn-state-machine.md.

Auto-resume on usage limit (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, usage-limit-patterns.ts (pure, unit-tested) parses the reset time and SessionAutoOps arms a timer for reset+2min, then sends Esc + continue. ⚠️ Respawn cycles are blocked while paused (isLimitPaused guard in onIdleDetected), which is what prevents /clear from wiping the paused conversation. Claude-mode only. → architecture-invariants#auto-resume-on-usage-limit

Plan-usage chip (statusLine telemetry, showPlanUsageLimits, per-device: desktop default ON, handhelds OFF via the mobile block in getDefaultSettings()): resolve it ONLY through planUsageChipEnabled() in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the statusLineTelemetry flag on session create). A chip shown without telemetry renders — forever. Codeman injects its own statusLine.command exporter which POSTs Claude's rate_limits blob to POST /api/status-telemetry. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is ours, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit message rather than showing live %. → architecture-invariants#plan-usage-chip-statusline-telemetry, docs/usage-limits-display-plan.md

Orchestrator: State machine that turns a user goal into a phased plan and drives it to completion: idle → planning → approval → executing → verifying → (replanning) → completed/failed. OrchestratorLoop (engine) delegates plan generation to orchestrator-planner and per-phase verification gates to orchestrator-verifier, executing phases via team agents/task-queue. State persists under the orchestrator key in state.json. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See docs/orchestrator-loop-architecture.md.

Cron (CronJobs): saved, named jobs on a recurring schedule (once/interval/daily/weekly) with per-job run history. ⚠️ Distinct from the legacy ScheduledRun (/api/scheduled, a run-now duration-bounded loop); the two never interact and keep separate Scheduled* / Cron* names. CronService reuses the existing session layer rather than rebuilding tmux logic. Next-run math is pure and unit-tested in cron-time.ts (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → architecture-invariants#cron-jobs, docs/cron-discovery.md

Remote sessions + remote SSH cases: a case can point at a remote host. The agent runs inside a durable remote tmux -L codeman-remote (session name codeman-ssh-<id>, deliberately failing the remote Codeman's SAFE_MUX_NAME_PATTERN so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running ssh. Attached (owned:false) sessions detach, never kill on tab close; owned ones propagate kill-session. A bounded-backoff watcher auto-reconnects dropped sessions (remoteAutoReconnect, default ON). ⚠️ Command-injection surface: every ssh command line must flow through buildSshConnectionArgs(), which shellescapes every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through POST /api/quick-start, not POST /api/sessions (which stat-validates workingDir locally and has no caseName). → architecture-invariants#remote-sessions-over-ssh, #remote-ssh-cases, docs/remote-sessions.md

Docker cases: a case can point at a container, with any of the five CLI backends running inside it. Like remote-SSH this is a LOCATION OVERLAY on cases, never a sixth SessionMode. Exactly one long-lived container per case, shared by all its sessions, so killing a session kills only that session's in-container tmux and never docker stop while siblings remain. The workspace is a real host dir bind-mounted at the same absolute path, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are seeded (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from docker commit so exports stay secret-free. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need CODEMAN_DOCKER_BRIDGE_HOOKS=1; otherwise idle detection falls back to output-based. → architecture-invariants#docker-cases, docs/docker-cases.md (user guide), docs/docker-cases-plan.md (design)

External CLI modes (OpenCode, Codex, Gemini, Antigravity): isExternalCliMode() in session.ts gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four require tmux with no direct PTY fallback, because secrets are injected via socket-scoped tmux setenv and never on the spawn command line. ⚠️ run*() in session-ui.js MUST unwrap the {success,data} envelope; reading the raw shape silently breaks the run. → architecture-invariants#external-cli-modes-opencode-codex-gemini

Run launch synchronization: the Run entrypoint holds an in-flight lock and disables #runBtn for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same w<n>-<case> name. _ensureCreatedSessionVisible() runs before selectSession(), and _onSessionCreated() stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → architecture-invariants#run-launch-synchronization

Unified session list: GET /api/sessions/unified 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 fold into their owning session via a claudeSessionId → Codeman id alias map, so resumed and /clear-respawned sessions do not appear twice. No terminal buffers in the response, unlike /api/sessions. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (PUT /api/session-order; pure merge helpers in src/session-order.ts, pushing device wins and server-only ids are never dropped). → architecture-invariants#unified-session-list-and-session-manager

Hook events: Claude Code hooks trigger via /api/hook-event. Key events: permission_prompt, elicitation_dialog, idle_prompt, stop, teammate_idle, task_completed. See src/hooks-config.ts; upstream hook semantics mirrored in docs/claude-code-hooks-reference.md.

Agent Teams: TeamWatcher polls ~/.claude/teams/, matches to sessions via leadSessionId. Teammates are in-process threads appearing as subagents. Enable: CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1. See docs/agent-teams/.

Circuit breakers: the Ralph breaker prevents respawn thrashing (CLOSED → HALF_OPEN → OPEN; reset via /api/sessions/:id/ralph-circuit-breaker/reset). Distinct: the PTY-exit breaker (session-pty-exit-breaker.ts) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit {clearBreaker:true} body on POST /api/sessions/:id/interactive; the frontend's auto-reattach in selectSession() sends no body and must never clear it. → architecture-invariants#circuit-breakers-ralph--pty-exit

Full-scrollback replay: GET /api/sessions/:id/terminal?full=1 returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (source='mux-full-history'), superseding the byte buffer so nothing duplicates. Only the FIRST buffer load after a page load requests full=1; tab switches keep the cheap ?tail= path. → architecture-invariants#full-scrollback-replay

Self-update (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (systemd, launchd, launchd-daemon, else none → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED scripts/self-update.sh that outlives the restart and writes progress to update-status.json, which the browser polls across the connection drop. src/web/self-update.ts splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → architecture-invariants#self-update

Attachments (live external document references; all wiring in file-routes.ts): a registry maps a stable attachmentId to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The magic-link scanner (codeman://attach?... in terminal output) is prompt-injectable, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension allowlist, not a blocklist. document-conversion-limiter.ts caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → architecture-invariants#attachments

Filesystem path picker (Link Existing "Browse" + the mobile keyboard's 📁 Path key): lazy one-directory browsing via GET /api/filesystem/browse, with GET /api/filesystem/preview for the tapped file. Inserts the path without Enter, so the prompt is never submitted; the sibling ⌫ All key clears only the unsent prompt and must never send the agent's /clear. ⚠️ This is a second file-serving surface and inherits neither the attachment confinement nor its ownership scoping — it allowlists Home, CASES_DIR, /mnt/d and CODEMAN_FILE_PICKER_ROOTS, blocks sensitive trees, and rejects symlink escapes after realpath. ⚠️ The optional sessionId is an ownership boundary that must be canAccessOwned-checked by hand (it does not go through findSessionOrFail), and in multi-user mode a non-admin gets only their own userSpacePath as a root: per-user spaces live INSIDE homedir(), so a Home root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert text/plain. → architecture-invariants#filesystem-path-picker

File Viewer edit mode (issue #212): the file-preview overlay edits workspace text files in place — GET .../file-content?edit=1 + PUT /api/sessions/:id/file-content, policy in src/config/file-editing.ts. This is a third file surface and the only one that WRITES: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/.git denies and an extension allowlist; writes are wx-temp + rename (no O_CREAT anywhere = edit-in-place is structural); optimistic concurrency via sha256 baseHash → 409. ⚠️ edit=1 never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → architecture-invariants#file-viewer-edit-mode, docs/file-viewer-edit-plan.md

Ultracode / workflow-run visualization (opt-in, default OFF): the Workflow tool writes a completion artifact only at run end, so live in-flight runs exist solely as transcript dirs. workflow-run-watcher.ts therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is STANDALONE and deliberately never imports or touches subagent-watcher.ts, despite reading the same tree. Two independent toggles: showUltracodeAgents (docked panel) and ultracodeFloatingWindows (floating windows); the watcher starts if either is on. → architecture-invariants#ultracode--workflow-run-visualization

Cross-session search: GET /api/search federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core searchSources() does substring matching with hard per-type caps: no regex (so no ReDoS) and no filesystem reads (so no traversal). The server-private externalPath is never read. → architecture-invariants#cross-session-search

Web tabs (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. NOT a sixth SessionMode (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are proxied through Codeman's own origin by default, because a direct iframe fails three ways at once: prod is HTTPS so http:// targets are blocked as mixed content, many dashboards send X-Frame-Options: DENY, and our own default-src 'self' CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (/webview/... is 'self'). ⚠️ The proxy is NOT an API surface: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-/api paths and is pinned by test/webview-auth-exemption.test.ts. ⚠️ Iframes omit allow-same-origin unless a dashboard is explicitly marked trusted, and Authorization/codeman_session are stripped upstream in both modes so CODEMAN_PASSWORD cannot leak. ⚠️ A sandboxed frame is opaque-origin, which breaks two things curl can never reproduce: its runtime-built root-absolute URLs escape <base> (fixed by an injected runtimeUrlShim()), and its same-host fetch/XHR are CORS-checked with Origin: null (fixed by buildProxyCorsHeaders() plus exempting the proxy from the global OPTIONS-204 short-circuit in registerSecurityHeaders). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → architecture-invariants#web-tabs, docs/web-tabs.md

Multi-user mode (opt-in --multiuser / CODEMAN_MULTIUSER=1, OFF by default): named users with scrypt-hashed passwords in ~/.codeman/users.json. Gated everywhere by isMultiUserMode(); when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ Not a security boundary at the agent layer: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through Session.owner and is enforced in findSessionOrFail, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → architecture-invariants#multi-user-mode, docs/multi-user-plan.md

Away digest: GET /api/away-digest aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in web/away-digest.ts. ⚠️ Returns {success:true,digest}, a legacy raw-ish shape consistent with the other raw GET handlers in system-routes.ts; frontend and tests read .digest. → architecture-invariants#away-digest

Ralph todo-config: per-session maxTodos (FIFO-eviction cap, default 500 = MAX_TODOS_PER_SESSION) + todoExpirationMinutes (auto-expiry, default 60) set via POST /api/sessions/:id/ralph-config (RalphConfigSchema, both .int().positive()). Stored on the tracker (setMaxTodos/setTodoExpirationMinutes) and persisted/read-back via RalphTrackerState (surfaced in the loopState getter → toState() + SSE broadcast → modal populateRalphForm), mirroring how maxIterations round-trips. Claude-only (skipped by isExternalCliMode).

Port interfaces: Routes declare dependencies via port interfaces (src/web/ports/). Routes use intersection types (e.g., SessionPort & EventPort).

Frontend

Frontend JS modules have @fileoverview with @dependency/@loadorder tags. Load order: constants.js(1) → i18n.js(1.5) → mobile-handlers.js(2) → voice-input.js(3) → notification-manager.js(4) → keyboard-accessory.js(5) → input-cjk.js(5.5) → sanitize-html.js(5.6) → app.js(6) → terminal-ui.js(7) → respawn-ui.js(8) → ralph-panel.js(9) → orchestrator-panel.js(9.5) → cron-ui.js(9.7) → settings-ui.js(10) → panels-ui.js(11) → ultracode-panel.js(11.5) → admin-ui.js(11.7) → session-ui.js(12) → webview-tabs.js(12.5) → mobile-overview.js(12.55) → entrance-animations.js(12.6) → ralph-wizard.js(13) → api-client.js(14) → subagent-windows.js(15) → ultracode-windows.js(15.5) → image-input.js(16). i18n.js translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; input-cjk.js handles CJK IME composition via an always-visible textarea below the terminal (window.cjkActive blocks xterm's onData).

Entrance animations (entrance-animations.js, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via data-tab-anim / data-term-anim / data-win-anim / data-line-anim on <html>. Defaults are the legacy theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are destroyed mid-animation on every re-render (_fullRenderSessionTabs() replaces the strip's innerHTML; _updateConnectionLinesImmediate() does svg.innerHTML = ''), so both are tracked by id and re-applied to the fresh element with a negative animation-delay to resume rather than restart. ⚠️ The terminal-pane styles may animate transform / opacity / clip-path only, xterm's FitAddon derives rows+cols from getComputedStyle(parent).width/height, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than beam transform the window, which moves the rect its connection line is aimed at; beam deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own codeman:*Anim localStorage keys (per-device, deliberately NOT in the .strict() SettingsUpdateSchema); picker in App Settings → Appearance, full per-surface lab at ?animlab=1.

Phone overview home screen (mobile-overview.js, phones only, per-device mobileOverviewEnabled, default ON): under 430px 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).

Command palette + shortcut registry: Ctrl/Cmd/Alt+K opens the session palette; shortcuts live in a rebindable registry (DEFAULT_SHORTCUTS/getShortcutRegistry()/matchesShortcutEvent() in app.js, overrides in settings.shortcutOverrides). ⚠️ Palette-chord keys must ALSO be swallowed in attachCustomKeyEventHandler (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ saveAppSettings() rebuilds settings from the DOM, so keys edited elsewhere (shortcutOverrides, showTokenCount, showCost) need explicit _prev carry-over. ⚠️ Smart copy (Ctrl+C) lives in that same handler: with a selection it copies, with none it must return true without preventDefault() or the interrupt is lost. copyTerminalSelection is deliberately absent from SHORTCUT_ACTIONS because the generic capture loop preventDefaults every match it dispatches. → architecture-invariants#command-palette-and-shortcut-registry

Per-device vs synced settings: the displayKeys set in settings-ui.js is a client-side merge policy, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; showPlanUsageLimits is additionally deleted from the incoming payload outright. Separately, SettingsUpdateSchema is .strict() and simply does not declare skin, showFileViewerButton, showCronButton, webglRendererEnabled, localEchoEnabled, cjkInputEnabled, or extendedKeyboardBar, so sending one of those is a validation error. The rest (showResponseViewer, showPlanUsageLimits, language, and most show* keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding both questions: membership in displayKeys, and presence in the schema.

Header button visibility: most header controls are opt-in and hidden by a marker class (btn-multimonitor--hidden, btn-response-viewer-header--hidden, btn-file-viewer--hidden, btn-cron--hidden) that applyHeaderVisibilitySettings() (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by renderIndexHtml. ⚠️ Hiding must go through the marker class: the base rules are display:inline-flex !important, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; test/mobile-header-buttons-policy.test.ts is the static guard. → architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron

Gesture control (camera hand-tracking overlay, opt-in, default OFF): CODEMAN_GESTURE=1 makes the feature available; gestureControlEnabled turns it on. The bundle is injected by renderIndexHtml only when enabled, which is why that method is async and reads settings with readSettings(true) (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). Source lives in packages/gesture-control/; edit there, run npm run build:gesture, and commit the regenerated bundle because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep MP_VERSION in fetch-gesture-assets.mjs in sync with @mediapipe/tasks-vision. → architecture-invariants#gesture-control-the-source-package

Theme skins / branding / i18n: skin selects a palette via data-skin on <html>, applied by an inline pre-paint script in index.html reading localStorage['codeman:skin'] to avoid a flash of wrong theme. ⚠️ A skin is four things that must stay in sync, and missing any one degrades silently: the html[data-skin="…"] token block in styles.css, the xterm ANSI palette in terminal-ui.js, the pre-paint allowlist, and the Settings picker (both in index.html). test/skin-themes.test.ts is the static guard. Light skins additionally need color-scheme: light and xterm minimumContrastRatio: 4.5, and applyTerminalSkin() must call the local-echo overlay's refreshFont() because it caches the terminal fg/bg. displayName changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. language (en/zh-CN) keeps English as the canonical source so live switching stays reversible. User display names flow through textContent/attribute APIs and the server title's HTML escaper, never innerHTML. → architecture-invariants#theme-skins

Foldable settings identity: responsive layout is width-driven via MobileDetection.getDeviceType(), but the localStorage namespace uses MobileDetection.isHandheldDevice() so an unfolded Android foldable keeps codeman-app-settings-mobile. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: OPPO Find N5 (unfolded) in test/mobile/devices.ts. → architecture-invariants#foldable-settings-identity

WebGL renderer toggle (webglRendererEnabled, per-device): the GPU-stall watchdog's sticky codeman-webgl-disabled marker survives page loads and is cleared only by an explicit OFF→ON save or ?webgl=force. ?nowebgl forces the DOM renderer per-load. → architecture-invariants#webgl-renderer-toggle

Phone toolbar: Enter replaces Shell (post-1.8.0): inside @media (max-width: 430px) btn-shell is display:none and btn-enter takes its slot (order: 4); starting a shell moved into the Run dropdown (Terminal / Shell → setRunMode('shell') → run() → runShell(), button label "Run SH"). runMode is z.string().max(20) server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.

⚠️ sendEnterKey() MUST go through terminal._core.coreService.triggerDataEvent('\r', true) — not sendInput(), and never a raw POST to /api/sessions/:id/input. localEchoEnabled defaults to MobileDetection.isTouchDevice(), so on every phone the characters you type are buffered in the LocalEchoOverlay and have never reached the PTY; the onData Enter branch in terminal-ui.js is what flushes pendingText first and only then sends \r (after an 80ms delay so text lands first). Sending a bare \r submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. KeyboardAccessory.sendKey() is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.

⚠️ Skin overrides outrank plain class rules. styles.css nests its skin block inside html:not([data-skin="og"]) { … }, so a bare .btn-toolbar rule in there resolves to specificity (0,2,1) and beats a .btn-toolbar.btn-x rule (0,2,0) in mobile.css regardless of load order. Toolbar-button colors set from mobile.css therefore need !important — that is why mobile.css leans on it so heavily. Symptom: only your !important properties land and everything else silently renders in generic toolbar grey.

Z-index layers: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, mobile.css), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), image popups (3000), local echo overlay (7).

Respawn presets: solo-work (3s/60min), subagent-workflow (45s/240min), team-lead (90s/480min), ralph-todo (8s/480min), overnight-autonomous (10s/480min).

Keyboard shortcuts: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.

Security

Full model: docs/security-architecture.md (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). Layer-by-layer detail with the history behind each: architecture-invariants#security-layers.

Layer The rule
Auth Optional HTTP Basic via CODEMAN_USERNAME (default admin) / CODEMAN_PASSWORD. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts)
Network bind Defaults to loopback. Non-loopback without a password starts but warns loudly. Classifier: network-auth-policy.ts
Host guard Always-on Host-header allowlist blocking DNS rebinding. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix
CSRF / Origin Always-on cross-site Origin guard on state-changing requests. A missing Origin is allowed so curl/CLI and hooks keep working. ⚠️ The body parser keeps text/plain RAW; auto-JSON-parsing it enabled simple-request CSRF
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 and hook-secret have separate buckets, so neither can lock out login
Hook bypass /api/hook-event + /api/status-telemetry skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires X-Codeman-Hook-Secret unconditionally (Codeman cannot detect a user's own loopback reverse proxy)
Tunnel Enabling a tunnel refuses without CODEMAN_PASSWORD unless exposure is acknowledged via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 or the per-request acknowledgeUnauthTunnel:true action field (never persisted)
Validation Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*)
Headers CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS

Security-relevant env vars: CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway).

SSE Event Registry

149 event constants in src/web/sse-events.ts (backend) and SSE_EVENTS in constants.js (frontend). Both must be kept in sync — they are currently exactly in sync, and the backend file's @fileoverview carries the per-category breakdown.

API Routes

~199 handlers across 21 route files in src/web/routes/: system (45), sessions (32), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the /webview/:cap/* proxy), mux (5), push (4), scheduled (4, legacy ScheduledRun), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has @fileoverview with endpoint details.

HTTP contract (stable since 0.9.x, see docs/versioning-policy.md; full envelope/status/error-code/SSE spec in docs/api-reference.md): responses use the ApiResponse<T> envelope — { success: true, data? } or { success: false, error, errorCode } (src/types/api.ts). /api/v1/* is a versioned alias of /api/* (URL rewrite in server.ts).

Adding Features

  • API endpoint: Types in src/types/ domain file, route in src/web/routes/*-routes.ts. Return the ApiResponse envelope ({ success: true, data }; errors via createErrorResponse() with proper status code). Validate with Zod schemas in schemas.ts.
  • SSE event: Add to src/web/sse-events.ts + SSE_EVENTS in constants.js, emit via broadcast(), handle in app.js (addListener()
  • Session setting: Add to SessionState, include in session.toState(), call persistSessionState()
  • App setting: decide per-device vs synced first. Per-device keys go in the displayKeys set in settings-ui.js and must NOT be added to SettingsUpdateSchema (it is .strict()). ⚠️ Anything in PUT /api/settings that acts on a setting (the toggleService watcher calls) must resolve from merged (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and body.x ?? default turns every omission into "apply the default" and silently resets live services. Pinned by test/routes/system-routes-settings-partial-put.test.ts.
  • Hook event: Add to HookEventType, add hook in hooks-config.ts:generateHooksConfig(), update HookEventSchema
  • Mobile feature: Add to relevant singleton, guard with MobileDetection.isMobile(). New header buttons must stay off phones (test/mobile-header-buttons-policy.test.ts).
  • New test: Pick unique port (search const PORT =). Route tests use app.inject() (no port needed) — see test/routes/_route-test-utils.ts.

Validation: Zod v4 (different API from v3). Define schemas in schemas.ts, use .parse()/.safeParse().

State Files

All in ~/.codeman/: state.json (sessions, settings, respawn, orchestrator, cron jobs/runs), mux-sessions.json (tmux recovery), settings.json (user prefs), push-keys.json + push-subscriptions.json, session-lifecycle.jsonl (audit log), update-status.json (self-updater progress, polled across the service restart), linked-cases.json, webviews.json (saved web-tab dashboard URLs), remote-hosts.json + remote-cases.json, docker-hosts.json + docker-cases.json + docker-exports/, subagent-window-states.json + subagent-parents.json (subagent window layout, GET/PUT /api/subagent-window-states/-parents), hook-secret (per-instance), users.json (multi-user, mode 0600) + admin-audit.jsonl, certs/ (self-signed TLS for --https), .env (CODEMAN_USERNAME/PASSWORD fallback for the codeman attach CLI). Transient: self-update-runner.sh. Multi-user case spaces live OUTSIDE the data dir at ~/codeman-users/<username>/cases (shared across instances like ~/codeman-cases, override CODEMAN_USER_SPACES_DIR).

Generated top-level dirs (all gitignored — don't edit or commit): dist/ (esbuild output), out/, coverage/, test-results/, tmp/, screenshots-echo-diag/. The committed gesture bundle (src/web/public/gesture/gesture-codeman.js) IS tracked, but its runtime wasm/model assets (src/web/public/gesture/wasm/, *.task) are fetched and gitignored.

Testing

Never run the bare full suite (npm test with no file argument): the default config includes the browser-driven suites (test/mobile/** and 3 other Playwright tests), which need a live server + chromium + environment-specific PNG baselines and will fail/hang locally. Run individual files, or test:ci for a broad sweep:

npm test -- test/<specific-file>.test.ts         # Single file (SAFE, uses config/vitest.config.ts)
npm test -- -t "pattern"                          # By name (SAFE)
npm run test:ci                                   # Everything except browser/perf suites — what CI runs
# npm test                                        # DON'T — includes browser/visual suites

Raw npx vitest skips config/vitest.config.ts; always use npm test -- or pass --config config/vitest.config.ts.

Config: Vitest with globals: true, fileParallelism: false. Timeout 30s, teardown 60s. config/vitest.ci.config.ts = same minus the browser/perf excludes — keep the two configs in sync when changing shared options.

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). ⚠️ Raw npx vitest without --config skips setup.ts and with it the temp-HOME isolation.

Ports: Pick unique ports manually, 3150+. Search const PORT = before adding new tests. Never 3000 (the live instance).

⚠️ Browser tests can pass vacuously on mobile input paths. Two traps, both hit on 2026-07-27 while fixing the phone Enter button: (1) driving input with app.sendInput('…') writes PAST the LocalEchoOverlay, so pendingText stays empty and any overlay bug is invisible — type with page.keyboard.type() instead; (2) headless Chromium reports MobileDetection.isTouchDevice() false even with hasTouch: true, so _localEchoEnabled is off and the local-echo branch never executes. Force it (app._localEchoEnabled = true) or the test proves nothing. Assert on real state (app._localEchoOverlay.pendingText, plus tmux -L codeman capture-pane -p -t <pane> for what actually reached the PTY), not on HTTP 200.

Testing against the live instance: prod is HTTPS-only on :3000 (curl -sk https://localhost:3000/...). ⚠️ w1/w2/w3 are the user's REAL sessions — never send input to them. Create your own throwaway session (POST /api/sessions then POST /api/sessions/:id/shell; creation alone leaves pid: null and no pane), test against that, and DELETE it by exact id when done.

Respawn tests: Use MockSession from test/mocks/index.ts (defined in test/mocks/mock-session.ts). Route tests: app.inject({ method, url, payload }) in test/routes/ — no live port needed. Mobile tests: Playwright suite in test/mobile/ (136 device profiles). Browser-testing infra and practices: docs/browser-testing-guide.md.

Debugging

tmux list-sessions                                 # List tmux sessions
curl localhost:3000/api/sessions | jq              # Check sessions
curl localhost:3000/api/status | jq                # Full app state
curl localhost:3000/api/subagents | jq             # Background agents
cat ~/.codeman/state.json | jq                     # Persisted state

Mobile screenshots: ~/.codeman/screenshots/, accessed via GET/POST /api/screenshots.

Performance & Limits

Target: 20 sessions, 50 agent windows at 60fps. Limits live in src/config/ (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable.

Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is clamped to ≤75% of max, because a trim ≥ max would disable BufferAccumulator trimming entirely and make memory unbounded; and browser xterm scrollback is a separate hardcoded 50k (DEFAULT_SCROLLBACK in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. The settings keys terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes are schema-validated but inert; only tmuxHistoryLimit is wired live. → architecture-invariants#buffers-uploads-and-terminal-history, docs/terminal-anti-flicker.md

Memory leaks (24+ hour sessions): use CleanupManager, clear Maps in stop(), guard async with if (this.cleanup.isStopped) return. Frontend: store handler refs, clean in close*(). Use LRUMap for bounded caches, StaleExpirationMap for TTL cleanup. Verify: npm test -- test/memory-leak-prevention.test.ts.

Scripts & Tunnel

install.sh (repo root, 69KB) is the public entry point: curl -fsSL <raw url> | bash installs Node/tmux if missing, clones to ~/.codeman/app, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: Tailscale (loopback bind + guided tailscale serve --bg <port> HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), LAN (0.0.0.0 + password prompt), or local-only; it preserves the existing binding on re-runs via read_existing_binding(). Tailscale state is detected dynamically from tailscale serve status --json (no marker files); the installer must NEVER tailscale serve reset or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). install.sh update, install.sh uninstall, and install.sh tailscale (retrofit Tailscale access onto an existing install) also exist; CODEMAN_NONINTERACTIVE=1 approves system changes for automation, CODEMAN_TAILSCALE=1 presets the Tailscale choice (never installs Tailscale non-interactively).

Other key scripts: scripts/tmux-manager.sh (safe tmux mgmt), scripts/tunnel.sh [quick|named] start|stop|status|url (quick = random trycloudflare URL, default; named setup|enable = fixed-hostname tunnel via scripts/codeman-tunnel-named.service; bare start|stop|url still means quick), scripts/run-beta.sh (isolated beta instance), scripts/build-agent-image.mjs (docker base image), scripts/self-update.sh (detached updater). Production services: scripts/codeman-web.service, scripts/codeman-tunnel.service. Always set CODEMAN_PASSWORD before exposing via tunnel.