Skip to content

Latest commit

 

History

History
368 lines (224 loc) · 128 KB

CLAUDE.md

File metadata and controls

368 lines (224 loc) · 128 KB

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). The user-facing manual is docs/wiki/ (mirrored to the GitHub wiki by CI; see the CI note under Additional Commands), and AGENTS.md deliberately just points 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), .dockerignore (the build context is the repo root, so Docker resolves it there and nowhere else) and install.sh (its raw URL is the published install one-liner). .claude-plugin/marketplace.json is root-only for the same reason: /plugin marketplace add Ark0N/Codeman reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is plugins/codeman/ (manifest + README + a MIRROR of skills/codeman/), and ⚠️ the plugin is a small separate directory on purpose: claude plugin install copies the plugin root into its cache, and a plugin root that carries a package.json gets an npm install at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to skills/codeman would dangle in the copy. skills/codeman/ stays the single source; scripts/sync-plugin.mjs mirrors it and writes package.json's version into both manifests inside version-packages, and test/plugin-manifest.test.ts pins the byte-identity, the versions, the absence of a package.json in the plugin root and that no other component (commands/, agents/, hooks/, .mcp.json, settings.json) rides along. npm run check:plugin runs that drift check plus both strict validations with EXPLICIT paths (plugins/codeman, .claude-plugin/marketplace.json): a bare . argument copied out of prose reads as a full stop and gets dropped, which surfaces as missing required argument 'path'. Needs the claude CLI, so it is a local check, not a CI step. Don't relocate those.

Quick Reference

Task Command
Dev server npm run dev (or npx tsx src/index.ts web)
Type check npm run typecheck (= tsc --noEmit)
Lint npm run lint (fix: npm run lint:fix)
Format npm run format (check: npm run format:check)
Tests npm test (the CI gate — safe to run bare) · one file: npm test -- test/<file>.test.ts · see Testing for the excluded suites
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).
  • A pre-push failure in a file you did not touch is another session's WIP. Push with CODEMAN_SKIP_PREPUSH=1 git push and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)

CRITICAL: Always Test Before Deploying

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. ⚠️ index.html itself is the exception: it is read ONCE into indexHtmlTemplate in the WebServer constructor, so editing markup in dev needs a server restart (edited .js/.css do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.

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 the user says COM (COM = patch, COM minor, COM major): Read docs/releasing.md and follow every step in order; never improvise a release from memory. It covers the changeset, npm run version-packages, the **Version** line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.

Version: 1.41.1 (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), Antigravity (agy, Google), Pi (pi.dev), Grok Build (grok, xAI), DeepSeek Harness (dsh) and OMP (omp) CLIs via pluggable CLI resolvers (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp').

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. Terminal session dashboard: codeman tui (--list to list, codeman tui <n> to attach).

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
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)
Regenerate the CLI catalogue npm run generate:cli-catalog (--check to fail on drift). Rewrites config/clis.stock.json and the marked block in install.sh from stock.ts. ⚠ Commit both. They are what install.sh and the Docker agent image read, since neither can import TypeScript; test/cli-catalog-sync.test.ts and a CI --check step fail if either goes stale. See docs/cli-registry.md
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
Browser-test exclusion check npm run check:browser-excludes (scripts/check-browser-test-excludes.mjs; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by config/vitest.ci.config.ts; add it to BROWSER_TEST_GLOBS in config/test-suites.ts
Pre-push hook Installed by npm install (scripts/git-hooks.mjs, via postinstall): runs the static CI checks (~10-40s) before git push. Skip once: CODEMAN_SKIP_PREPUSH=1 git push. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written pre-push is never overwritten; installs ONLY into the repo's own <git-common-dir>/hooks (worktree-safe; a core.hooksPath elsewhere, e.g. a global one, is left alone)
Production logs journalctl --user -u codeman-web -f
Detached server codeman web -d (--status, --stop; pidfile+log at dataPath('web.pid'/'web.log')). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation

CI: .github/workflows/ci.yml (push to master/main + PRs, Node 22) runs two jobs: (1) check:lockfile, typecheck, lint, check:frontend-syntax, check:browser-excludes, format:check, then a server boot smoke test (tsx src/index.ts web --port 3151 must answer /api/status within 30s); (2) the unit/integration test suite via npm run test:ci (config/vitest.ci.config.ts — excludes the browser-driven test/mobile/** suite, perf-* benchmarks, and the Playwright tests in BROWSER_TEST_GLOBS; globs live in config/test-suites.ts), followed by the packages/xterm-zerolag-input package tests (a bare npx vitest run in that directory; its vitest is hoisted by the root npm ci, so no separate install, and npm test at the root does NOT run them). npm test runs this same config, so local green == CI green. Tests are tmux-safe in CI: TmuxManager no-ops all shell commands under VITEST (see Testing). A third workflow, wiki-sync.yml, fires only on master pushes touching docs/wiki/** and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via docs/wiki/). browser-suite.yml runs npm run test:browser (minus codex-predictive-echo) nightly and on demand via workflow_dispatch; it is informational and never gates a push or PR.

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/** (lint only src/**/*.ts), and .prettierignore then exempts most of src/web/public/*.js (app.js, styles.css, mobile.css, index.html, and 15 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. ⚠️ Input must END with \r or Enter is never sent: sendInput() only issues send-keys Enter when the payload contains a carriage return, a \r-less POST /api/sessions/:id/input still succeeds (send-and-wait even reports delivered:true) while the text sits unsubmitted on the composer, and any wait burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so "echo A\necho B\r" runs the joined echo Aecho B. ⚠️ Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+\r sent at readiness sits unsent with 0 tokens and a wait burns its timeout. So the SERVER verifies every programmatic write that carried a \r: SubmitVerifier (session-submit-verifier.ts, armed from writeViaMux) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own promptGlyph) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's sendwait keeps its own copy of the loop (_composer_text in skills/codeman/preamble.sh) for servers that predate this. The shift+tab footer only means the composer painted, never that Enter is accepted. ⚠️ A prompt must never be written into the pane as ONE burst: Claude Code 2.1.283 takes a <text>\r burst of ~100+ chars as a paste, its \r lands as a NEWLINE and the prompt strands (a later raw \r does not recover it, a tmux send-keys Enter does). So POST .../input routes a plain prompt (isPlainPromptInput(), route-helpers.ts: printable text + exactly one trailing \r) through writeViaMux even without useMux, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit useMux:false keep the direct write
  • ESM only — Never require(), use await import(). tsx masks CJS/ESM issues in dev but production breaks
  • Package ≠ product name — npm: aicodeman, product: Codeman. Release renames tags accordingly. Both aicodeman and codeman bin aliases are installed (package.json bin)
  • Global regex lastIndex — Shared g-flag patterns in loops must reset lastIndex = 0 first, or use the execPattern() helper in utils/regex-patterns.ts (resets automatically)
  • envOverrides flow CLAUDE_CODE_* / OPENCODE_* / CODEX_* / GEMINI_* / GOOGLE_* / ANTIGRAVITY_* / PI_* / GROK_* / XAI_* / DSH_* / DEEPSEEK_* env vars, plus exact-key CLAUDE_CONFIG_DIR — Set via POST /api/sessions { envOverrides }, stored on Session._envOverrides, exported by tmux-manager.buildEnvExports() at spawn time, persisted in SessionState.envOverrides. Do NOT write these to <case>/.claude/settings.local.json — that's the old path and creates UI/disk drift. (GOOGLE_* is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) CLAUDE_CONFIG_DIR (#255, exact match via ALLOWED_ENV_KEYS in schemas.ts) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside ~/.claude/projects, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks projects back into the shared tree (ln -s ~/.claude/projects <configDir>/projects). ⚠️ It is also one of claude's privilegedEnvKeys (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via envOverrides is admin-only, and a non-granted owner's already-persisted CLAUDE_CONFIG_DIR is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see session-env-clamp.ts). → architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir
  • Effort is NOT an env var — never carry effort as CLAUDE_CODE_EFFORT_LEVEL: the env var hard-locks effort and blocks in-session /effort switching (incl. ultracode). It flows as the dedicated effort payload field → Session._effort → claude --effort <level> for regular levels incl. max (the settings effortLevel key is enum(["low","medium","high","xhigh"]).catch(undefined) — max gets SILENTLY dropped there), or claude --settings '{"ultracode":true}' for ultracode (rejected by --effort). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in applyEnvOverrides(). See buildEffortCliArgs() in session-cli-builder.ts, tests in test/effort-injection.test.ts
  • The advisor rides --settings, NEVER the --advisor flag: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the advisorModel payload field → Session._advisorModel (persisted, so respawn and reboot restore keep it) → the advisorModel key in the launch's ONE --settings JSON, merged with ultracode and the statusLine exporter by buildAdvisorSettings() (session-cli-builder.ts). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (claude --advisor haiku exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ isAdvisorModel() (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: /advisor still switches it in-session. App Settings key claudeAdvisorModel (SYNCED, '' = leave it to the CLI). Remote/docker quick-start refuses it, like effort, and so does a remote attach on POST /api/sessions. Tests: test/advisor-model.test.ts
  • Model choice: a persistent default in settings.local.json, a per-session --model, never 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. A caller that wants one session on a model without touching the case sends model on POST /api/sessions instead: it goes out as claude --model <id>, writes nothing, wins over the app-wide default, and is persisted as SessionState.model so both recovery paths relaunch on it (test/routes/session-routes-claude-model.test.ts, test/session-model-recovery.test.ts). ⚠️ Claude only, via the model.source capability (cliTakesSessionModel()), never a mode check: refused for other CLIs and on a remote attach, and published by toState() for claude alone (cron hands its Claude default to every CLI with a model).
  • Multi-CLI prefix discipline — env-var prefix is CLI-specific (CLAUDE_CODE_* vs OPENCODE_* vs CODEX_* vs GEMINI_* vs ANTIGRAVITY_* vs PI_* vs GROK_* vs DSH_*) and the ALLOWED_ENV_PREFIXES allowlist in schemas.ts enforces this; non-prefix exceptions are exact keys in ALLOWED_ENV_KEYS (currently only CLAUDE_CONFIG_DIR), never a widened prefix. Gemini additionally allowlists the broad GOOGLE_* namespace (intentional: Vertex AI auth needs GOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists XAI_* for the same vendor-namespace reason (XAI_API_KEY is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via /login or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh settings.yaml can nominate ANY env var as a provider credential (apiKeyEnv), so only the vendor namespaces DSH_* (launcher inputs incl. DSH_PERMISSION_MODE) and DEEPSEEK_* (DEEPSEEK_API_KEY/DEEPSEEK_BASE_URL) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: docs/opencode-integration.md, docs/pi-integration.md, docs/grok-integration.md, docs/deepseek-integration.md
  • Zod .optional() rejects null — accepts undefined only. When the frontend builds a request body with JSON.stringify, an explicit null field is preserved on the wire and fails validation with INVALID_INPUT. Convert null → undefined before stringifying (e.g. field: value ?? undefined), or declare the schema .nullish(). This has caused real shipped bugs twice
  • Local-echo overlay stays on screen: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional totalRows in RenderParams; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, _shrinkPaddingToFit() (mobile-handlers.js) must never shrink main's padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are position: fixed, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: packages/xterm-zerolag-input/test/overlay-renderer.test.ts, test/mobile-keyboard-bottom-padding.test.ts.
  • xterm-zerolag-input is single-source — BOTH echo addons live ONLY in packages/xterm-zerolag-input/src/, bundled into TWO gitignored vendor files: vendor/xterm-zerolag-input.js (buffer overlay, entry zerolag-input-addon.ts) and vendor/xterm-predictive-echo.js (codex write-through, entry predictive-echo-addon.ts) — dev by scripts/postinstall.js, prod by scripts/build.mjs. app.js/terminal-ui.js only consume them via new LocalEchoOverlay(terminal) / new PredictiveEchoOverlay(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 bundles. 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', …), and any new tmux -L caller through resolveTmuxSocketName() (both in config/instance.ts): the TUI shells out to tmux from a second process, and a hardcoded codeman there would point a beta instance at prod's panes. → 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. ⚠️ The flip side of that: since Linux has no prebuild, npm install needs a C/C++ toolchain there (make, g++, python3), so install.sh checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with not found: make. Do not drop that step. ⚠️ 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
  • User-chosen paths are probed BOUNDED, never with existsSync/statSync (#516): a linked case or a workingDir can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async stat/lstat/readFile holds one of libuv's threadpool workers (4 by default, shared with every fs, dns.lookup and crypto call) until the mount returns. On a request or spawn path use probePath()/probePathKind() (utils/bounded-path-probe.ts, read its @fileoverview). ⚠️ unknown is NEVER absent: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass { pastCap: true }, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still unknown. User-facing errors for it go through describeUnknownPath(), so a refused probe is not reported as a broken folder.
  • 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)

Read before touching a subsystem: respawn → docs/respawn-state-machine.md; Ralph → docs/ralph-wiggum-guide.md; orchestrator → docs/orchestrator-loop-architecture.md; cron → docs/cron-discovery.md; DeepSeek (dsh is a PROFILE LAUNCHER, not an agent) → docs/deepseek-integration.md; TUI (src/tui/, a CLIENT of the server, never a second brain; only tui-app + tui-client do IO over a pure core) → docs/tui-plan.md. Pure helpers are split out of session.ts and the other large files on purpose, so tests exercise the shipped code: keep new logic that way.

★ = 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/ — flat files plus the cli-registry/ subdir, no barrel (index.ts) exists; import from the specific file. ⚠️ There are TWO config/ directories: the repo-root config/ holds tooling only (ESLint, knip, the vitest configs, test-suites.ts), while runtime config lives in src/config/. Throughout this file a bare config/<name>.ts in a code context means src/config/<name>.ts.

Utilities: src/utils/ — re-exported via index. Key: CleanupManager, LRUMap (⚠ NOT in the barrel — import from ./utils/lru-map.js directly), StaleExpirationMap, BufferAccumulator, stripAnsi, Debouncer, KeyedDebouncer. Also: claude-cli-resolver/opencode-cli-resolver/codex-cli-resolver/gemini-cli-resolver/antigravity-cli-resolver/pi-cli-resolver/grok-cli-resolver/deepseek-cli-resolver/omp-cli-resolver (CLI path resolution, one per SessionMode, all nine sharing the lookup chain in cli-executable-resolver: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ pi-, grok- and deepseek-cli-resolver additionally probe the binary's identity, since pi is a generic name, grok has npm squatters, and Debian ships an unrelated dsh), file-query (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), string-similarity (fuzzy matching), regex-patterns (ANSI/token/spinner patterns), assertNever (exhaustive checks), token-validation (auth tokens), nice-wrapper (process priority), shell-resolver (⚠ resolves a real login shell for mode: 'shell'; the literal string $SHELL used to be expanded by the SERVER's shell, which is empty in a container), event-loop-monitor (a sync execSync freezes the port while the process stays alive, leaving no trace), dependency-checker + dependency-report (the codeman doctor probe engine, registry in config/dependency-registry.ts).

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

Agent wait primitives: bounded long-polls: GET /api/sessions/:id/wait, GET .../wait-output (literal substring, never regex) and wait/waitTimeout on POST .../input. Registry session-wait-registry.ts (pure), bounds config/agent-wait.ts. ⚠️ A timeout is a 200 (wait.timedOut). ⚠️ stop/blocked exist for claude and deepseek ONLY (rule lives in hooksAvailableForMode()): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must notifySignal('exit') BEFORE cancelAll(); hangup abort listens on reply.raw (guarded by writableFinished), never req.raw; liveness comes from isPaneDead, never session.pid. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or wait-output markers. Packaged as the skills/codeman skill (codeman skill install, plugin marketplace, or injection behind agentSkillEnabled, SYNCED, default OFF, into the directory each CLI scans: capabilities.agentSkillDir, .claude/skills for claude, .agents/skills for the others, none for pi): injection is add-only, marker-owned (applyAgentSkill), refuses symlinks, and refreshes a marker-owned user-level copy (refreshUserAgentSkill). → architecture-invariants#agent-wait-primitives, docs/api-reference.md

Agent CLI (codeman agent …, src/cli-agent.ts): the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no jq; seedAgentSessionPreamble covers every local session whose CLI takes the skill, capabilities.agentSkillDir; every pane still exports CODEMAN_MUX/CODEMAN_SESSION_ID/CODEMAN_API_URL). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries X-Codeman-Parent-Session, only spawn's quick-start carries X-Codeman-Agent-Origin (pinned in test/cli-agent.test.ts), auth (src/codeman-credentials.ts, shared with codeman attach and the TUI) from CODEMAN_PASSWORD or the data dir's .env (the same reader codeman attach uses — readCodemanEnvFile). Verbs return exit codes (0 ok / 1 error / 2 timeout / 3 exited / 4 refused) instead of calling process.exit, so test/cli-agent.test.ts drives every verb against a recording fake transport plus the real httpRequest against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without CODEMAN_MUX=1+CODEMAN_API_URL (never guess a URL); send transmits printable text + \r only (inputRefusal: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's Ctrl+C is app_exit), ESC exists solely as interrupt with no \r; rm fails closed (deleteRefusal: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while ls prints prefixes, so resolveSessionId maps a short id through GET /api/v1/sessions and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's PARENT_SESSION_ID_MIN_PREFIX, so rm 9 can never pick an arbitrary session. ⚠️ A 400 for --until stop on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. spawn on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's spawn_worker deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so SID=$(…) works. ⚠️ Two server answers that look like success and are not: delivered:false without duplicate (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and wait.ended:true, signal:null (the worker died during a stop/idle wait: the registry satisfies only waiters that listed exit, then cancels the rest) — both exit 3, checked BEFORE the happy paths in waitExitCode. → README "codeman agent", docs/api-reference.md

MCP server (codeman mcp, src/mcp-server.ts): a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like codeman agent (reuses its httpRequest, inputRefusal, buildSendBody; no new route). Seven tools: list_sessions, start_session, send_prompt, wait_for_session, read_output, interrupt_session, delete_session. ⚠️ Dual-era: answers the legacy initialize handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request _meta with protocolVersion + clientCapabilities, server/discover, resultType: "complete" on every modern result); a modern tools/list/server/discover MUST carry ttlMs + cacheScope (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from --url/CODEMAN_API_URL, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless --allow-any-session; never its own session. ⚠️ read_output returns text ONLY, no structuredContent (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest config/mcp-registry/server.json (mcpName in package.json, version kept by scripts/sync-plugin.mjs). Tests: test/mcp-server.test.ts.

Agent-created case marker (src/agent-case-marker.ts): a case dir that POST /api/quick-start CREATES for an agent-driven spawn (signal: the preamble's X-Codeman-Agent-Origin header / agentOrigin field, else a resolved parentSessionId) gets .codeman-agent-case.json, published as agentCreated on GET /api/cases; GET /api/cases/agent-created is the cleanup listing (inUse, modifiedAt) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on DELETE /api/cases/:name (the ONE recursive-delete path), and the sweep excludes inUse cases. ⚠️ Changing the preamble's headers requires bumping CODEMAN_PREAMBLE. → architecture-invariants#agent-created-case-marker

Agent preamble cache GC: the §0 preamble seeded per local session whose CLI takes the skill (capabilities.agentSkillDir) ($XDG_CACHE_HOME/codeman-agent-<id>.sh) is now REMOVED with the session (removeAgentSessionPreamble from _doCleanupSession, killMux only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (pruneAgentSessionPreambles(this.sessions.keys()), once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and AGENT_PREAMBLE_MAX_AGE_MS (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in test/agent-skill.test.ts.

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

⚠️ A ❯ sighting is NOT the end of a turn, and neither is silence. Claude redraws the composer all through a turn, and its working line (✻ Actualizing… (13m 23s · …)) is invisible to SPINNER_PATTERN, keyword lists and the raw stream. _confirmIdle() (session.ts) requires the pane to go quiet AND the SCREEN (capturePaneText() + the working-line pattern) to agree; a sustained run of repaints (session-activity.ts) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (capabilities.workDetect), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ workingLine is config-supplied and runs on the PTY hot path, so it must compile through compileVersionRegex() in BOTH the schema refine and _workingLinePattern() (ReDoS guard; null, not throw). → architecture-invariants#idle-detection-composer-glyph-and-working-line

⚠️ A turn that ENDED waiting for its own workers is working, not idle. Claude closes such a turn with ✻ Waiting for 1 dynamic workflow to finish (background agents / ultracode) and resumes by itself; capabilities.workDetect.awaitingLine makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (isAwaitingWorkers()), never pane-wide and never on the stream. → architecture-invariants#idle-detection-composer-glyph-and-working-line

⚠️ A quiet pane is not always a pane that wants you. A CLI can declare an optional capabilities.workDetect.watchingLine (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into Session.watching and notePrompt() opens that idle item ALREADY acknowledged, so no surface alerts. Only idle is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you. Tests: test/session-watching.test.ts, test/watching-no-alert.test.ts.

An exited agent in a live pane (paneExit, #446): panes use remain-on-exit on, so /exit leaves a pane, session and pid that look alive; TmuxManager.startPaneExitWatcher() publishes SessionState.paneExit via session:updated. ⚠️ Never set status: 'error' or null the pid for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by Session.paneExitApplies); an absent #{pane_dead_status} is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via cleanupSession() (pane-exit-sweep.ts): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (paneLifecycleInFlight) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → architecture-invariants#an-exited-agent-in-a-live-pane-paneexit

Dead-pane respawn resume pin (_buildRespawnPaneOptionsWithResumePin(), session.ts): recovering a dead pane, like a custom-model restartCli(), must pin the conversation or claude refuses the reused --session-id. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never _claudeSessionId, adds nothing when none is backed, and is never applied to remote or docker sessions. → architecture-invariants#dead-pane-respawn-the-resume-pin

Workspace-trust dialog auto-accept (session-trust-dialog.ts, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (compactScreenText(), all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind \r (newer versions highlight "No, exit" first): trustDialogNextKey() returns ONE key per re-read frame (arrow, then Enter only once ❯ is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window TRUST_DIALOG_WINDOW_MS (90s), two-marker match (isTrustDialogScreen), attempt cap TRUST_DIALOG_MAX_ATTEMPTS (6). ⚠️ Read capturePaneText(); only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (_trustDialogTimer, cleared in _clearAllTimers()), not rely on PTY output. → architecture-invariants#workspace-trust-dialog-auto-accept

Process-tree walks are bounded (proc-tree.ts, pure): collectDescendants(pid, byParent) is the ONE descendant traversal, fed by one cached ps -eo pid=,ppid= snapshot (refreshProcSnapshot() in tmux-manager.ts: async, in-flight-shared, and ANY error discards the result rather than caching a truncated ps). ⚠️ Never walk a process tree with per-node pgrep or unbounded recursion (the unbounded version took a machine down): the walk must terminate on cycles, cap depth (PROC_WALK_MAX_DEPTH) and node count (PROC_WALK_MAX_NODES), and never spawn anything. ⚠️ Keep it in its own module so the test exercises the shipped code, and report truncation through onTruncated naming both caps, never silently. → architecture-invariants#process-tree-walks-are-bounded

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 (showPlanUsageLimits, per-device: desktop default ON, handhelds OFF): resolve DISPLAY only through planUsageChipEnabled() (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by readPlanUsageTelemetryEnabled() (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; GET /api/settings must never write. ⚠️ A save sends showPlanUsageLimits ONLY when it flips the chip on that device (planUsageCollectionFlip()), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL claude --settings flag (resolveStatusLineCliCommand), never written to disk, WRAPPING a user's own statusLine, posting to POST /api/status-telemetry. Codex comes from a read-only account/rateLimits/read poll (main bucket only). The statusline's prompt_cache object (Claude Code v2.1.251+) is kept by StatusTelemetrySchema (every documented scalar field, null-tolerant; parsePromptCache() in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as cache:until HH:MM / cache:cold (only when the user has no statusline of their own) and in Session Options → Respawn (#promptCacheStatus, fed by Session.promptCache in toState() plus the change-only session:promptCache SSE event, worded by the pure CodemanPromptCache.format): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a Context compacted at HH:MM note after a compaction or a tool-result clearing (stamped on the first warm report whose recache_tokens_if_cold is null after one that carried it, never from expected_rebuilds, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ Session.setPromptCache() stamps compactedAt onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor promptCache, like displayModel) and the readout is time-aware (a passed expiresAt reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → 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 in a durable remote tmux -L codeman-remote, fronted by a LOCAL tmux pane running ssh. Attached (owned:false) sessions detach, never kill; owned ones propagate kill-session. Auto-reconnect (remoteAutoReconnect, default ON) revives ONLY when remoteTmuxSessionAlive() proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (classifyRemoteAliveExit), never stdout. ⚠️ Command-injection surface: every ssh command line must flow through buildSshConnectionArgs(); never hand-build one. ⚠️ Remote file reads (src/remote-files.ts, attachment routes too) take browser paths only as shellescaped tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by src/remote-ssh-limiter.ts, and pick the host from the SESSION, never the path; no writes over ssh (the PUT guard must precede local path validation). ⚠️ Route remote cases through POST /api/quick-start, not POST /api/sessions. → architecture-invariants#remote-sessions-over-ssh, #remote-ssh-cases, docs/remote-sessions.md

Wake-on-LAN (remote-wake.ts): optional RemoteHost.wakeMac (magic packet) or RemoteHost.wakeCommand (single executable, no shell, takes precedence) lets HTTP input, POST /api/sessions/:id/wake and the user's create/attach (ensureHostAwake) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, handleRemoteSessionDropped, boot recovery or cron-service.ts, and GET /api/sessions/:id/reachability must never wake. ⚠️ Detection is a throttled bare TCP probe; never add ServerAliveInterval, and a jumpHost/socksProxy/ProxyCommand host is reachability-UNKNOWN (isProbeable()): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (host-wake-ui.js) must not promise queued input. Waiting requests use the 40 s REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS. → architecture-invariants#remote-ssh-cases

Docker cases: a case can point at a container running any CLI mode inside it, a LOCATION OVERLAY on cases, never a SessionMode. One long-lived container per case, shared by its sessions: killing a session kills only its in-container tmux, never docker stop while siblings remain. The workspace is bind-mounted at the same absolute path. Credentials are seeded, never shared RW. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. A drifted config (label hash) REFUSES the launch. ⚠️ An adopted container (DockerCase.owned === false) is only execed into: never create, start, stop, restart, remove, docker commit or docker pause it; fail closed. Test owned === false, never truthiness. ⚠️ Apply owned AFTER dockerConfigHash. ⚠️ Run modes come from the CONTAINER (availableModes), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's overlays.docker.rootCommand, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs CODEMAN_DOCKER_BRIDGE_HOOKS=1 for in-container hooks. → architecture-invariants#docker-cases, docs/docker-cases.md

Docker Compose deployment (docker/): Codeman runs in a container and spawns Docker cases as SIBLING containers via the host socket, never nested. resolveDockerDaemonMountSource() maps HOME bind sources into the daemon's namespace (CODEMAN_DOCKER_HOST_HOME); CODEMAN_CASES_PATH makes workspaces resolve to the same absolute path on both sides. ⚠️ CODEMAN_CASES_PATH must move every consumer: resolve it only via config/cases-dir.ts. ⚠️ .dockerignore matches whole paths: keep **/.env or docker/.env secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: Start-Codeman.sh pre-creates them, and docker/entrypoint.sh (root, cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID] against cap_drop: ALL, KILL for tini; pinned by the test) fixes ownership then drops to PUID:PGID via setpriv, never re-owning foreign dirs. ⚠️ Append /opt/codeman-cli and ~/.local/bin to PATH, never prepend. ⚠️ server.Dockerfile, the compose file and .env.example feed the self-updater's environment gate (docs/docker-self-update.md). → architecture-invariants#docker-compose-deployment, docs/docker-compose.md

CLI registry (src/config/cli-registry/): every run mode is a CliEntry (discovery, launch argv template, env handling, capabilities, and the overlays behind remote/docker pane commands). No code outside stock.ts may branch on a CLI id: use a capability field or a NAMED PROFILE (profiles.ts); test/cli-registry-no-id-branching.test.ts and test/frontend-cli-no-id-branching.test.ts enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep external, hooks and altScreen independent; never derive one from another. ⚠️ Config regexes (discovery.version.regex, capabilities.workDetect.workingLine, capabilities.workDetect.watchingLine, capabilities.workDetect.awaitingLine, capabilities.modelDetect.screenLine) must compile through compileVersionRegex(). ⚠️ privilegedParams[].param names a LAUNCH PARAM, not the legacy <Mode>Config field (bridged only by launch.legacyConfigAliases); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of buildRemoteLaunchCommand are not covered by the pane-command golden. ~/.codeman/clis.json overrides entries. It is WRITTEN only by the opt-in CLI management routes (cliManagementEnabled, default OFF; /api/clis, cli-registry-routes.ts), and only through mutateRegistryFile() in registry-writer.ts, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → architecture-invariants#cli-registry, docs/cli-registry.md

External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP): isExternalCliMode() in session.ts gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI capabilities.workDetect data, not this gate. All eight require tmux, no direct PTY fallback (secrets go via socket-scoped tmux setenv, never the command line). ⚠️ run*() in session-ui.js MUST unwrap the {success,data} envelope. ⚠️ Codex uses predictive write-through echo, never the buffer overlay: _predictHookOnData must never return (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ Pi: no bypass flag, never invent one; approveProjectTrust executes repo code, so it is in the clamp's materialize branch; never wire --api-key. ⚠️ Grok: alwaysApprove is stripped for non-granted owners (only-if-sent). ⚠️ DeepSeek: the agent is a PROFILE (Run gates on isDeepSeekRunnable()); the permission switch is the DSH_PERMISSION_MODE env var, so clampEnvOverridesForOwner() must DROP DSH_PERMISSION_MODE, DSH_HOME and DEEPSEEK_BASE_URL for non-granted owners; hooksAvailableForMode() is per-SESSION for it (pass sessionHookOptions(session)) and is never a stand-in for mode === 'claude'; answers come from deepseek-transcript.ts, paired by header cwd + boot window, never newest-mtime. ⚠️ OMP: OMP_AUTH_BROKER_URL/_TOKEN are clamped the same way. → architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp

DeepSeek web UI (POST/GET/DELETE /api/deepseek/web, deepseek-web-server.ts): the Run menu's "DeepSeek web UI..." entry supervises ONE background dsh web child process, deliberately NOT a shell session. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (--trusted-host, last asker wins), killed on shutdown via stopDeepSeekWeb() (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both POST and DELETE must stay behind canUsernameRunPrivilegedCommands (booting a profile runs its plugin code; the server is shared). → architecture-invariants#deepseek-web-ui

Custom Model Endpoint Profiles (opt-in, customModelEndpointsEnabled, SYNCED, default OFF; docs/custom-model-endpoints.md): points a session at a user-configured OpenAI-compatible endpoint (store custom-model-hosts.ts, ~/.codeman/custom-model-hosts.json, 0600). authStyle is bearer or api-key, never both headers (hangs the server). The per-CLI redirect is registry data, capabilities.customModelInjection (env / configContentEnv / configDir / unsupported), computed by the pure custom-model-injection.ts; ⚠️ configDir writes an isolated per-session config, NEVER the user's real ~/.codex/~/.pi/~/.omp/grok config. ⚠️ Claude applies via Session.restartCli() (a relaunch in the existing pane, so the conversation is pinned as resumeSessionId), the rest one-shot at launch; retired env keys must also be setenv -u'd (_pendingEnvUnsets), since tmux env survives respawn-pane. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (__customModel), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's privilegedEnvKeys, and ANTHROPIC_* stays out of claude's allowedPrefixes. ⚠️ The Run-menu picker builds entries from window.__codemanCustomModelClis (escaped via escapeScriptJson()), never a hardcoded CLI id list, and launches through run() via a temporary _runMode swap, never setRunMode(). → architecture-invariants#custom-model-endpoint-profiles

⚠️ llama-swap endpoints (one model at a time): the apply routes check GET /running and return requiresConfirmation before evicting a model another live session uses; confirmedSwap and confirmedContext are SEPARATE flags and must stay so. Claude alone gets a context floor (CLAUDE_MIN_SAFE_CONTEXT_TOKENS); context is parsed from /running's cmd, never trusted from /props. Backend log lines come from llama-swap's /api/events upstream source, never /logs. → architecture-invariants#custom-model-endpoint-profiles

MCP server sync (opt-in, mcpSyncEnabled, SYNCED, default OFF; src/mcp-sync.ts, GET/POST /api/mcp-sync): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (~/.claude.json, ~/.codex/config.toml, ~/.gemini/*, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, capabilities.mcpConfig ({ path, format, relocation? }), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in MCP_SYNC_ONLY_TOOLS (src/mcp-sync-targets.ts), take part only when installed or already configured, and their companion settings.json (disabledMcpServers) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as <file>.codeman-bak, one apply at a time, else 409). ⚠️ A file that receives copied env/headers (secrets) is left 0600, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (describeMcpSyncError), never the parser's own message (smol-toml and V8 both quote source). ⚠️ mcpConfig.relocation names the env var the CLI reads to move its file (CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME, GEMINI_CLI_HOME, and COPILOT_HOME for Copilot), resolved from the SERVER env at call time; a relative value reports the target skipped, never a guessed write, and a per-session envOverrides relocation is not followed. Tests must pass home (which drops the process.env default) or clear those vars first. → docs/cli-registry.md (MCP server sync), docs/api-reference.md, docs/wiki/Settings-Reference.md

Auto-named sessions (autoNameSessions, SYNCED, default OFF): a placeholder tab (w3-myapp) takes its first real prompt as a title in the <prefix>: <title> form, so the case identity and w<n> counter survive. Ownership is SessionState.nameSource (placeholder | auto | manual; the name setter / PUT /api/sessions/:id/name makes it manual, never touched again). ⚠️ applyAutoName() flips to auto even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: SessionWriteOptions.fromUser is set by the browser WS path and POST /api/sessions/:id/input ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call trackUserInput()). ⚠️ The pure tracker (session-auto-name.ts) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ nameSource also decides --name: only a manual name is pinned on the claude CLI (Session.cliPinnedName), since --name is also the /resume title; a rename appends a custom-title row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to manual). Tests: test/session-auto-name.test.ts. → architecture-invariants#auto-named-sessions-first-prompt--tab-title

Maintainer bot (external): the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at scripts/pr-bot/. It moved OUT of this repository on 2026-09-14, to ~/codeman-cases/prbot/ (its own private git repo, systemd unit codeman-pr-bot, guide + agent rules in its own README.md and CLAUDE.md). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named prbot-<n> / dscbot-<n> on the local Codeman and holds clones under ~/.codeman/pr-bot/, so those session names and that data dir are taken; it also fetches PR heads into refs/pr-bot/* of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.

Unified session list: GET /api/sessions/unified merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core src/services/unified-session-service.ts), backing the Cmd+K Session Manager, pinning and cross-device tab order (PUT /api/session-order, src/session-order.ts). ⚠️ Transcript history is THREE stores (~/.claude/projects, ~/.omp/agent/sessions, ~/.codex/sessions), folded via the claudeSessionId → Codeman id alias map (not Claude-only despite the name). ⚠️ resumeId is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → architecture-invariants#unified-session-list-and-session-manager

Owner tab layouts (tab-layout*.ts + GET/PUT /api/tab-layout): named tab GROUPS over the flat strip, scoped per owner (@single when multi-user is off), persisted as tabLayouts in state.json. The frontend reads AND edits it (tab-layout-browser.js + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry focus: true ("Add to Focus" in the row/web-tab menu, the named setFocus operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (_renderTabFocus()), its own element in #tabRail ABOVE #sessionTabs, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (copyRef in tab-layout-browser.js, parseRef/mapRef in tab-layout.ts) must carry focus like placement: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: sessionOrder, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (role=tree, headers owning role=groups, one roving tabindex=0); the strip, sidebar and flat rail stay tablist/tab. ⚠️ Every browser write is a named operation through ONE serialized PUT /api/tab-layout at a time (createEditCoordinator): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the pagehide keepalive in _persistPendingTabLayoutEdits is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (tabGroupId on POST /api/sessions / /api/quick-start) into TabLayoutService.sessionCreated(), which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced spawnedTabsFollowParent setting (default OFF: pinned manual in the creating write), read fresh in sessionCreated(). ⚠️ A legacy order PUT marks a child manual ONLY when the requested order splits it from its parent's block (applyLegacySessionRank), and the browser never echoes create/delete through PUT /api/session-order: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ TabLayoutService is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto PUT /api/session-order via tab-layout-legacy-order.ts; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (markRestorationComplete/assertDeletionReady()): a failed restore must leave the layout untouched or live tabs get pruned. → architecture-invariants#owner-tab-layouts

Hook events: Claude Code hooks trigger via /api/hook-event (permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, idle_prompt, stop, teammate_idle, task_completed, prompt_submitted); see src/hooks-config.ts and docs/claude-code-hooks-reference.md. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from restoreMuxSessions(), gated by workspaceHooksEnabled (SYNCED, default ON). ⚠️ Route that decision through applyWorkspaceHooks, never call ensureCodemanHooks at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as permission_prompt (RED alert), not elicitation_dialog (MCP elicitation). → architecture-invariants#hook-events-and-workspace-hook-installation

Reboot restore (src/reboot-restore.ts pure + web/reboot-restore-registry.ts + routes/reboot-restore-routes.ts + reboot-restore-ui.js): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first await, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, rearmAutoResumeSchedule: false, and pass nameSource through. ⚠️ Undo a failed rebuild with discardPartiallyBuiltSession(), NEVER cleanupSession(). Claude-mode only, never remote/docker. Tests: test/reboot-restore.test.ts. → architecture-invariants#reboot-restore

Approvals Inbox (approvalsInboxEnabled, SYNCED, default OFF; the store and answer endpoints run regardless): web/approval-inbox.ts is an in-memory, claude-only queue fed by /api/hook-event, at most ONE item per session, answered via POST /api/approvals/:id/answer through writeViaMux (menu answers never carry \r). ⚠️ Accept option digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified verifyStillAnswerable(); the heuristic working signal alone may resolve idle items only. ⚠️ applyCapture() is ADD-ONLY for options. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass selectSession(id, { auto: true }); _ackDelivery spends the IDLE alert only. ⚠️ handleInit seeds tab alerts from GET /api/approvals REGARDLESS of the setting. → architecture-invariants#approvals-inbox

Read My Mind intent profiles (readMyMindEnabled, SYNCED, default OFF; docs/readmymind-plan.md): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (transcript:user_prompt), never the input paths; the listener must stay inside startTranscriptWatcher()'s if (!watcher) block. Store src/intent-store.ts → intents.json, ⚠️ written 0600 tmp+rename and never fed to /api/search (prompts carry secrets). Routes in readmymind-routes.ts (ownership via findSessionOrFail WITH req); predictor = pure readmymind-context.ts + IO in readmymind-collectors.ts + readmymind-predictor.ts, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/textContent ONLY and nothing auto-sends, ever. Frontend readmymind-ui.js. → architecture-invariants#read-my-mind-intent-profiles

Voice dictation via Claude (claudeVoiceEnabled, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI /voice backend) instead of Deepgram; the browser captures, src/web/voice-stream.ts relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (src/claude-credentials.ts): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; voice-pcm-worklet.js borrows voice-input.js's ?v= token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → architecture-invariants#voice-dictation-via-claude

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 whole tmux scrollback ALONE (source='mux-full-history'), superseding the byte buffer. First load of each non-shell TUI session requests it (_fullHistoryLoaded); Shell selection, drop recovery, clear and a tile's load use SHELL_LOAD_QUERY (constants.js), a bounded window of tmux's RENDERED history (?full=1&tail=1MiB&alt=recording&lines=10000), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client lines= bound below the history ceiling that left lines in tmux as truncationReason: 'tail', and alt=recording falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded ?full=1&tail= window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind Load full history. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (TerminalTile._pullHistory, terminal-tile.js); keep the two in step. → architecture-invariants#split-pane-sessions ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never CUP), so no line-deleting transform may run over it; those skips key on isFullCapture, never on ?full=1 alone. ⚠️ A re-pull must never shrink the buffer (_replayWouldShrinkBuffer()). ⚠️ captureCols/captureRows are absent when no frame was positioned: test Number.isFinite, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: _scheduleDroppedOutputRecovery re-arms (bounded by DROP_RECOVERY_MAX_ATTEMPTS) while _onSessionNeedsRefresh reports no repaint, but never after a capture-fetch 'deadline'. → architecture-invariants#full-scrollback-replay

Terminal scrollback strip + wheel/touch forwarding: codex/claude/gemini get the FULL strip (alt-screen, 3J, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, 3J kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: CliCapabilities.altScreen JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on useMux: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on (cliMouseTracking: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ getClaudeCliVersion() must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: _shouldReportMouseToCli() gates all three report sites on cliMouseTracking (from _recordStrippedMouseMode(), session.ts), or a plain shell prints the reports as literal text. ⚠️ A TerminalTile (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never activeSessionId), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read _logScrollRouting() before diagnosing a scroll report. → architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding Detached start + service install: codeman web -d relaunches the same entry script detached:true (setsid); nohup is not what makes it survive. ⚠️ Both -d and service install must REFUSE when a server is already up on this data dir (pidfile + /api/status probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll /api/status until the child answers or dies. --stop must verify the pid still looks like Codeman (ps -o command=) before signalling. Unit/label names live only in config/service-names.ts. service install bakes the installing shell's PATH into the unit and never writes CODEMAN_PASSWORD into it. → architecture-invariants#detached-start-and-service-install

Self-update (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (systemd, launchd, launchd-daemon, docker-compose, else none). The work runs in a DETACHED scripts/self-update.sh writing update-status.json, polled across the restart; pure helpers in src/web/self-update.ts. ⚠️ Compose: the restart kills the script, so nothing may be appended after the restarting marker; the repo must stay a host bind mount over /opt/codeman and the image must keep devDependencies + toolchain. ⚠️ evaluateEnvironmentGate() refuses releases that change server.Dockerfile/docker-compose.yaml or add .env.example keys, re-evaluated on POST /api/system/update; unknowns fail OPEN, but the exit-to-restart needs --restart-by-exit 1 (CODEMAN_RESTART_BY_EXIT=1 only in the Compose file). ⚠️ Keep the agent CLIs in server.Dockerfile pinned. → docs/docker-self-update.md, architecture-invariants#self-update

Reverse-proxy base path (--base-url / CODEMAN_BASE_URL, default /; pure single source src/config/base-path.ts, normalized to '' or /foo): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: stripBasePath() in Fastify's rewriteUrl (routes stay prefix-agnostic; unprefixed requests still answer), one onSend hook rebasing Location, renderIndexHtml rewriting <base href> + injecting window.__CODEMAN_BASE__, and CodemanBase.url() (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/window.open/src) through CodemanBase.url(). ⚠️ Web-tab proxy egress goes through proxyPrefixFor(cap, basePath); ingress parsers stay base-agnostic. ⚠️ --base-url must ride buildWebArgs and resolveServicePlan. Tests: test/base-path.test.ts. → architecture-invariants#reverse-proxy-base-path

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

Prompt uploads (POST /api/sessions/:id/paste-image: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in image-input.js and keyboard-accessory.js): a pick is written to <workspace>/.codeman-uploads/ and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because <workspace>/.codeman/ would BE the data dir when the workspace is the home directory, and self-ignoring through a .gitignore of * written ONCE with wx (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in paste-image-gc.ts (UPLOAD_DIR_NAMES): the pre-move .claude-images/ receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through uploadDirs()) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ uploadDirs() is async and probes the workspace BOUNDED first (probePathKind(), the #516 rule: an unknown path is skipped, never touched; the sweep keeps the stall cap and the delete passes pastCap), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains getDataDir() (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under MAX_PASTE_IMAGE_BYTES (50MB) after a client-side canvas re-encode. A VIDEO is never buffered: ?kind=video streams it to disk under MAX_PASTE_VIDEO_BYTES (1GB, env CODEMAN_MAX_PASTE_VIDEO_BYTES), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's VIDEO_ATTACHMENT_EXTENSIONS, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (consumeVideoToken, 3/min) beside the image bucket (consumePasteToken, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its workingDir is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (probePathKind(session.workingDir, { pastCap: true }), the #516 rule) before the first disk touch and refuses an unknown mount through describeUnknownPath() (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ The client classifies a pick through ONE helper, _promptAttachKind() (MIME type first, then the name's extension, because some Android file managers hand over a File with none); every pick and paste filter (Attach, drop, the tile grid's drop in tile-grid.js, the Compose and paste-dialog pickers, the clipboard handlers), the flag and the re-encode gate read it, never file.type directly. ⚠️ A pick that lands while the tile grid owns the terminal goes to the PTY, never the main terminal's overlay: that terminal is parked and hidden then, and a tile's Enter cannot flush it (_tilesOwnTerminal() in the _attachFromToolbar guard). Tests: test/toolbar-attach.test.ts, test/mobile-prompt-composer.test.ts, test/paste-image-gc.test.ts, test/paste-image-dir-shared.test.ts, the paste-image block of test/routes/session-routes.test.ts. → architecture-invariants#buffers-uploads-and-terminal-history

File-path links (terminal + chat): a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (FILE_PATH_LINK_PATTERN / absoluteFilePathPattern() in constants.js) feeds the xterm link provider AND _linkifyFilePaths(), a fresh instance per call (lastIndex). Both call findFilePathLinks(), which adds ~/, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against workingDir (resolveLinkedFilePath), ~/ only server-side on the session's host (attachment route), and terminalPathLine() joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the folder grid: POST /api/sessions/:id/folders guards + GRANTS the folder (folder-listing.ts, own registry, never the 200-capped attachment registry), files come by bare name via …/folders/:folderId/files/:name/* through resolveServableAttachmentPath plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; ~/ is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (POST /api/sessions/:id/attachments with notify: false), never by widening file-content/file-raw or file-stream-manager's tail -f allowlist. ⚠️ TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (never a second list), and widening READ must never widen RUN: html/htm/svg stay download-only, other text is inert text/plain+nosniff. ⚠️ The ONE place HTML renders is /html-view/<cap>/... (html-view-routes.ts, html-view-capabilities.ts): POST /api/sessions/:id/html-view mints a capability for the file's own directory, every response carries Content-Security-Policy: sandbox WITHOUT allow-same-origin (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add allow-same-origin without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (isBroadHtmlViewRoot); the preview renders by default and the Page pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: test/routes/html-view-routes.test.ts. Media extensions are single-sourced in attachment-registry.ts. ⚠️ XLSX previews parse in the BROWSER, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in src/web/CLAUDE.md (Spreadsheet preview). → architecture-invariants#file-path-links-terminal--response-viewer

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

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

Files panel search (COD-236, the q param on GET /api/sessions/:id/files): compileFileQuery() (utils/file-query.ts, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (MAX_QUERY_LENGTH, 256) query compiles to null, keeping the default tree response byte-identical. ⚠️ Never compile a glob into a RegExp (*a*a*a… backtracks and freezes the event loop for the whole server): globMatch() is a two-pointer wildcard walk. → architecture-invariants#files-panel-search

Git status indicator (showGitStatus, per-device, default OFF; src/git-workspace-status.ts, routes/git-status-routes.ts, git-status-ui.js): the bottom-bar indicator and its panel read GET /api/sessions/:id/git-status and /git-diff, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer unsupported. ⚠️ git-diff takes repo/path/kind only as keys matched against the workspace's own repository list (findWorkspaceRepo()) and that repository's current status, never as paths. ⚠️ Keep --no-optional-locks, core.fsmonitor=false and log.showSignature=false on every call and --no-ext-diff --no-textconv on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at $HOME or above is ignored (isUnrelatedAncestor()), and is identified by one cached rev-parse before any full status runs. Everything git supplies renders via textContent. Tests: test/git-workspace-status.test.ts, test/routes/git-status-routes.test.ts, and test/git-status.browser.test.ts (browser suite, not in the gate).

Raw file bodies are streamed and range-aware: file-raw, the attachments /raw route, GET /api/download and the rendered-HTML route GET /html-view/<cap>/... share sendFileBody(), advertise Accept-Ranges: bytes and answer Range with 206 + Content-Range (single-range, parser in src/web/http-range.ts); without it <video> cannot seek. The size cap (MAX_FILE_DOWNLOAD_BYTES, default 2GB, env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via reply.hijack(), so sendRawStream must copy the status onto reply.raw by hand or a partial body ships as 200. ⚠️ Closing the preview must pause and unload media (_stopFilePreviewMedia), since a detached HTMLMediaElement keeps playing. → architecture-invariants#raw-file-bodies-streamed-and-range-aware

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

Clone a repository as a case (issue #236, Add Case → Clone Repo): POST /api/cases/clone clones synchronously into the caller's case space (bounded by GIT_CLONE_TIMEOUT_MS, no job store); POST /api/cases/clone-preflight checks anonymous cloneability and lists refs. Core in src/git-clone.ts. ⚠️ The URL is a code-execution surface: refuse every :: form and a leading -, spawn only argv arrays with -- before operands. ⚠️ Stay non-interactive (gitNonInteractiveEnv()) or the open request hangs; never collect credentials, refuse user:password@ URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing CLAUDE.md kept, hooks merged, repo .claude/settings* warned about). → architecture-invariants#clone-a-repository-as-a-case

Cross-session search: GET /api/search federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core searchSources() does substring matching with hard per-type caps: no regex (so no ReDoS) and no filesystem reads (so no traversal). The server-private externalPath is never read. PAST sessions (#261) come from session-history-index.ts, a capped snapshot of the unified list filled outside the request path (/api/sessions/unified publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through canAccessOwned() on read; history rows carry jumpTo.kind:'resume-session', since a closed session has no tab to select. → architecture-invariants#cross-session-search

Web tabs (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, NOT a SessionMode, and is proxied through Codeman's own origin (/webview/<cap>/...). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (test/webview-auth-exemption.test.ts). ⚠️ Iframes omit allow-same-origin unless trusted, and Authorization/codeman_session are stripped upstream in both modes. ⚠️ Egress guard: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (webview-egress.ts); use the undici package's own fetch + Agent, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (openLinkThroughWebTabIfLoopback), but never auto-route *.localhost (prompt-injectable DNS). Capabilities are revoked on logout (revokeOwner). → architecture-invariants#web-tabs, docs/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 invariants live in src/web/CLAUDE.md: module load-order notes and every src/web/public/ UI subsystem (tab strip, sidebar, rail and tab layouts, tile grid, split pane, lineage lines, home screens, settings surface, skins and fonts, mobile keyboard/toolbar/composer, terminal touch, copy and paste, file-preview markdown, XLSX preview limits, z-index stack, connection-loss UI, SSE watchdog, keyboard shortcuts incl. the Ctrl+W owner decision). Claude Code loads it automatically when the Read tool opens a file under src/web/. ⚠️ Reading files through Bash (cat, sed, grep) does NOT load it, so Read src/web/CLAUDE.md before editing anything in src/web/public/ if it is not already in context.

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

PTY and browser terminal geometry (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so syncTerminalGeometry() (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare fitAddon.fit()), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with Session.ptyGeometry, and a client adopts its COLUMNS only (never rows); ptyGeometry is null without a live pane. → architecture-invariants#pty-and-browser-terminal-geometry

Service worker precache (sw.js + scripts/build.mjs): BUILD_ID and HASHED_ASSETS are build-generated and the build THROWS unless each declaration appears exactly once; caches.match must pass ignoreSearch: true because cacheBustAssets appends ?v= to hashed names. → architecture-invariants#service-worker-precache-and-cache-key

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)
Lost-frame page The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a GET/HEAD carrying Sec-Fetch-Dest: iframe|frame, Accept: text/html and mode navigate (or none), for a path that is NOT a registered route (never /api/, /ws/, /q/), is answered BEFORE the credential checks with the static web-tab recovery page (lostWebviewFramePage: no reflected input, default-src 'none' plus its own script hash, no-store). / is the one registered route also admitted, only when the request carries neither codeman_session nor Authorization (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly / and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in docs/api-reference.md. Pinned by test/webview-auth-exemption.test.ts + test/webview-lost-root-frame.test.ts
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_*/PI_*/GROK_*/XAI_*/DSH_*/DEEPSEEK_*)
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

Event constants live in src/web/sse-events.ts (backend) and SSE_EVENTS in constants.js (frontend). Both must be kept in sync; test/sse-registry-parity.test.ts pins it. ⚠️ hook:agent_working is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's @fileoverview carries the per-category breakdown, including the two Web tab events.

API Routes

One module per domain in src/web/routes/ (plus a barrel; ls src/web/routes/ for the current list). Beyond the /api routes: the /webview/:cap/* proxy, the /ws/voice/stream relay and the terminal WebSocket. Each file has @fileoverview with endpoint details.

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

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: a test that constructs WebServer itself binds port 0 (new WebServer(0, …), then server.boundPort), and a raw http/net/Fastify/ws server listens on 0 and reads address().port; mobile tests get theirs from createTestServer() and read boundPort too. test/test-ports-guard.test.ts refuses a fixed port in either. 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, owner tab layouts), mux-sessions.json (tmux recovery), settings.json (user prefs), push-keys.json + push-subscriptions.json, session-lifecycle.jsonl (audit log), update-status.json (self-updater progress, polled across the service restart), docker-env-applied.json (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by Start-Codeman.sh, read by the self-updater's environment gate), docker-build-source.json (Compose deployment only: the checkout's HEAD commit and package-lock.json hash the codeman-node-modules/codeman-dist volumes currently reflect, written by both Start-Codeman.sh and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), linked-cases.json, webviews.json (saved web-tab dashboard URLs), remote-hosts.json + remote-cases.json, docker-hosts.json + docker-cases.json + docker-exports/, subagent-window-states.json + subagent-parents.json (subagent window layout, GET/PUT /api/subagent-window-states/-parents), hook-secret (per-instance), users.json (multi-user, mode 0600) + admin-audit.jsonl, intents.json (Read My Mind intent profiles, mode 0600), webhook.json (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of settings.json and never returned by /api/webhook, which is admin-only in multi-user mode), certs/ (self-signed TLS for --https), .env (CODEMAN_USERNAME/PASSWORD fallback for the codeman attach CLI), install.log (installer step output, written by install.sh's run_step) and tailscale-rename (the node name before install.sh renamed it, so uninstall can offer it back; both installer-route only). Transient: self-update-runner.sh. Multi-user case spaces live OUTSIDE the data dir at ~/codeman-users/<username>/cases (shared across instances like ~/codeman-cases, override CODEMAN_USER_SPACES_DIR).

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

Videos and GIFs: only GIFs a public page embeds are tracked (docs/images/), and .gitignore blocks *.mp4/*.webm/*.mov/*.gif everywhere else. Masters, promo cuts, drafts and captures live in the local, gitignored media library pr/media/ (guide pr/media/README.md, generated INVENTORY.md via node pr/media/inventory.mjs); never leave a render in tmp/ or out/. A GIF leaving docs/images/ needs a hotlink check first (articles embed raw master URLs). → docs/media-assets.md

Testing

npm test is the gate and is safe to run bare — it runs config/vitest.ci.config.ts, exactly what CI runs, so local green means CI green.

npm test                                          # The gate — what CI runs
npm test -- test/<specific-file>.test.ts          # Single file
npm test -- -t "pattern"                          # By name

Three suites are deliberately left out, because they cannot pass on an arbitrary machine. Each has its own runner, and a failure there means "not runnable here", not a regression:

npm run test:browser   # Playwright + chromium, live server; codex-predictive-echo also needs a real codex binary
npm run test:mobile    # the above plus environment-specific PNG baselines (own config, own pretest vendor step)
npm run test:perf      # wall-clock benchmarks — need an otherwise idle machine
npm run test:all       # literally everything; fails ~87 tests on a clean master here, which is why it is not the default

⚠️ npm test cannot see those suites, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note in src/web/CLAUDE.md). The browser suite (not mobile or perf) also runs nightly in .github/workflows/browser-suite.yml, which catches a regression after it lands, not before.

⚠️ A file filter must match the runner. npm test -- test/mobile/keyboard.test.ts matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (npm run test:mobile -- <file>, npm run test:browser -- <file>, npm run test:perf -- <file>). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.

Raw npx vitest skips the config (and with it setup.ts); always use npm test -- or pass --config.

Config: Vitest with globals: true, fileParallelism: false. Timeout 30s, teardown 60s. config/vitest.config.ts is the everything-config behind test:all; config/vitest.ci.config.ts is the gate and derives its excludes from config/test-suites.ts, which is also what vitest.browser.config.ts and vitest.perf.config.ts derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them.

Tmux safety: under vitest (VITEST), TmuxManager no-ops ALL shell commands (IS_TEST_MODE in src/tmux-manager.ts), docker IO is no-op'd likewise, and Session spawns an echo PTY (TEST_PTY_SCRIPT) instead of attaching tmux. test/setup.ts gives each file a temp HOME/USERPROFILE and strips CODEMAN_PASSWORD/CODEMAN_USERNAME, CODEMAN_GESTURE and CODEMAN_INSTANCE/CODEMAN_DATA_DIR/CODEMAN_TMUX_SOCKET (pinned by test/test-env-isolation.test.ts). ⚠️ CODEMAN_DATA_DIR overrides the temp HOME, so never drop its strip; strip CODEMAN_INSTANCE in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via safeRmHomeTree(). ⚠️ Raw npx vitest without --config skips setup.ts and its isolation. → architecture-invariants#test-isolation-tmux-docker-and-home

Ports: every in-process test server binds port 0 (the one exception, test/codex-predictive-echo.test.ts, starts a separate lab server process on 3222). A test that constructs WebServer reads server.boundPort (mobile tests: createTestServer(), then server.boundPort); a raw http/net/Fastify/ws server listens on 0 and reads address().port. test/test-ports-guard.test.ts fails a WebServer built on any other port and a raw listen on a number or …PORT constant. A test that needs a port nothing listens on binds 0, reads it and closes (closedPort() in test/daemon-control.test.ts), never "the server's port + 1". 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/ (device profiles in test/mobile/devices.ts). Browser-testing infra and practices: docs/browser-testing-guide.md.

Debugging

tmux -L codeman list-sessions                      # Codeman's own socket (bare `tmux` shows the default one)
curl -sk https://localhost:3000/api/sessions | jq  # Check sessions (prod is HTTPS-only; dev on :3000 is plain http)
curl -sk https://localhost:3000/api/status | jq    # Full app state
curl -sk https://localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq                     # Persisted state

Legacy screenshots (deprecated): GET/POST /api/screenshots still read and write ~/.codeman/screenshots/, but the upload page that fed them is gone, they log a one-time deprecation warning, and they are removed in a later MAJOR, after at least one MINOR release that carries the warning (docs/versioning-policy.md). To hand a file to an agent use POST /api/sessions/:id/paste-image (see Prompt uploads).

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. tmux <3.7 allocates history-limit at pane creation, while tmux 3.7+ can resize live panes (lowering the value can discard retained lines); already-evicted lines never return. The settings keys terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes are schema-validated but inert; only tmuxHistoryLimit is wired. → 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) is the public curl | bash installer: it installs Node/tmux/git/build tools, clones to ~/.codeman/app, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in ~/.codeman/install.log). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (read_existing_binding()). ⚠️ Compose the hand-start env only in start_command_hint/export_bind_env, and call stop_background_helpers before exec. ⚠️ Tailscale rename is opt-in (default NO, never under --yes/non-interactive); NEVER tailscale serve reset, touch a mapping it did not create, run tailscale funnel or advertise a Service (test/install-sh-invariants.test.ts). ⚠️ Stay bash 3.2 clean (no declare -A, mapfile, namerefs, ${x,,}, here-strings, empty-array expansion under set -u). ⚠️ Execute only commands from the generated CLI block (CLI_INSTALL_CMD_TRUSTED, npm run generate:cli-catalog). → architecture-invariants#installsh-the-public-installer

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.