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.mdis the fast-execution protocol (it removes ceremony, never the safety rules here). The user-facing manual isdocs/wiki/(mirrored to the GitHub wiki by CI; see the CI note under Additional Commands), andAGENTS.mddeliberately just points here.This file is in
.prettierignoreon purpose. Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (agent-*.jsonlbecameagent-\_.jsonl, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not runprettier --writeon 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 inpackage.json, andSECURITY.mdis 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 topackage.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) andinstall.sh(its raw URL is the published install one-liner)..claude-plugin/marketplace.jsonis root-only for the same reason:/plugin marketplace add Ark0N/Codemanreads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists isplugins/codeman/(manifest + README + a MIRROR ofskills/codeman/), and⚠️ the plugin is a small separate directory on purpose:claude plugin installcopies the plugin root into its cache, and a plugin root that carries apackage.jsongets 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 toskills/codemanwould dangle in the copy.skills/codeman/stays the single source;scripts/sync-plugin.mjsmirrors it and writespackage.json's version into both manifests insideversion-packages, andtest/plugin-manifest.test.tspins the byte-identity, the versions, the absence of apackage.jsonin the plugin root and that no other component (commands/,agents/,hooks/,.mcp.json,settings.json) rides along.npm run check:pluginruns 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 asmissing required argument 'path'. Needs theclaudeCLI, so it is a local check, not a CI step. Don't relocate those.
| 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 |
You may be running inside a Codeman-managed tmux session. Before killing ANY tmux or Claude process:
- Check:
echo $CODEMAN_MUX- if1, you're in a managed session - NEVER run
tmux kill-session,pkill tmux, orpkill claudewithout confirming - Use the web UI or
./scripts/tmux-manager.shinstead 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-currentimmediately before committing. Observed 2026-07-27: another session rangit checkout -b feat/web-tabs, a commit silently landed there instead of master, and the follow-upgit push origin mastercheerfully 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:masterthengit branch -f master HEAD. Nevergit checkout masterto "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, sincetscis the first step and the build gates on it. That is not your bug to fix.⚠️ tscstill EMITS on type errors, so a failednpm run buildleaves a rebuiltdist/index.jscompiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blockedtsc, run the asset stage ofscripts/build.mjs(everything after thetsc/chmodlines 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 pushand 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.)
NEVER COM without verifying your changes actually work. For every fix:
- Backend changes: Hit the API endpoint with
curland verify the response - Frontend changes: Use Playwright to load the page and assert the UI renders correctly. Use
waitUntil: 'domcontentloaded'(notnetworkidle— SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values - 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.
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)
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).
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.
- Single-line prompts only —
writeViaMux()sends text+Enter separately; multi-line breaks Ink.⚠️ Input must END with\ror Enter is never sent:sendInput()only issuessend-keys Enterwhen the payload contains a carriage return, a\r-lessPOST /api/sessions/:id/inputstill succeeds (send-and-wait even reportsdelivered:true) while the text sits unsubmitted on the composer, and anywaitburns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so"echo A\necho B\r"runs the joinedecho 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+\rsent at readiness sits unsent with0 tokensand awaitburns its timeout. So the SERVER verifies every programmatic write that carried a\r:SubmitVerifier(session-submit-verifier.ts, armed fromwriteViaMux) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's ownpromptGlyph) 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'ssendwaitkeeps its own copy of the loop (_composer_textinskills/codeman/preamble.sh) for servers that predate this. Theshift+tabfooter 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>\rburst of ~100+ chars as a paste, its\rlands as a NEWLINE and the prompt strands (a later raw\rdoes not recover it, a tmuxsend-keys Enterdoes). SoPOST .../inputroutes a plain prompt (isPlainPromptInput(), route-helpers.ts: printable text + exactly one trailing\r) throughwriteViaMuxeven withoutuseMux, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicituseMux:falsekeep the direct write - ESM only — Never
require(), useawait import().tsxmasks CJS/ESM issues in dev but production breaks - Package ≠ product name — npm:
aicodeman, product: Codeman. Release renames tags accordingly. Bothaicodemanandcodemanbin aliases are installed (package.jsonbin) - Global regex
lastIndex— Sharedg-flag patterns in loops must resetlastIndex = 0first, or use theexecPattern()helper inutils/regex-patterns.ts(resets automatically) envOverridesflowCLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*/PI_*/GROK_*/XAI_*/DSH_*/DEEPSEEK_*env vars, plus exact-keyCLAUDE_CONFIG_DIR— Set viaPOST /api/sessions { envOverrides }, stored onSession._envOverrides, exported bytmux-manager.buildEnvExports()at spawn time, persisted inSessionState.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 viaALLOWED_ENV_KEYSinschemas.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 symlinksprojectsback into the shared tree (ln -s ~/.claude/projects <configDir>/projects).⚠️ It is also one of claude'sprivilegedEnvKeys(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 viaenvOverridesis admin-only, and a non-granted owner's already-persistedCLAUDE_CONFIG_DIRis stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (seesession-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/effortswitching (incl. ultracode). It flows as the dedicatedeffortpayload field →Session._effort→claude --effort <level>for regular levels incl.max(the settingseffortLevelkey isenum(["low","medium","high","xhigh"]).catch(undefined)—maxgets SILENTLY dropped there), orclaude --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 inapplyEnvOverrides(). SeebuildEffortCliArgs()insession-cli-builder.ts, tests intest/effort-injection.test.ts - The advisor rides
--settings, NEVER the--advisorflag: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as theadvisorModelpayload field →Session._advisorModel(persisted, so respawn and reboot restore keep it) → theadvisorModelkey in the launch's ONE--settingsJSON, merged with ultracode and the statusLine exporter bybuildAdvisorSettings()(session-cli-builder.ts).⚠️ The flag EXITS at launch on any pairing the CLI refuses (claude --advisor haikuexits 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:/advisorstill switches it in-session. App Settings keyclaudeAdvisorModel(SYNCED,''= leave it to the CLI). Remote/docker quick-start refuses it, likeeffort, and so does a remote attach onPOST /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 (claudeModelinsettings.json) is read bysession-ui.jsat session create (wins over the legacy 1M-Opus togglesopusContext1m/opusContext1mEnabled), sent as themodelOverridepayload field, andupdateCaseModel()(hooks-config.ts) writes/deletes themodelkey in<case>/.claude/settings.local.json. This is the intended exception to the envOverrides rule above: model legitimately lives insettings.local.json(a soft default — in-session/modelstill works); env vars do not. A caller that wants one session on a model without touching the case sendsmodelonPOST /api/sessionsinstead: it goes out asclaude --model <id>, writes nothing, wins over the app-wide default, and is persisted asSessionState.modelso both recovery paths relaunch on it (test/routes/session-routes-claude-model.test.ts,test/session-model-recovery.test.ts).⚠️ Claude only, via themodel.sourcecapability (cliTakesSessionModel()), never a mode check: refused for other CLIs and on a remote attach, and published bytoState()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_*vsOPENCODE_*vsCODEX_*vsGEMINI_*vsANTIGRAVITY_*vsPI_*vsGROK_*vsDSH_*) and theALLOWED_ENV_PREFIXESallowlist inschemas.tsenforces this; non-prefix exceptions are exact keys inALLOWED_ENV_KEYS(currently onlyCLAUDE_CONFIG_DIR), never a widened prefix. Gemini additionally allowlists the broadGOOGLE_*namespace (intentional: Vertex AI auth needsGOOGLE_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 allowlistsXAI_*for the same vendor-namespace reason (XAI_API_KEYis 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/loginor the server process's own env.⚠️ DeepSeek repeats pi's lesson exactly: a dshsettings.yamlcan nominate ANY env var as a provider credential (apiKeyEnv), so only the vendor namespacesDSH_*(launcher inputs incl.DSH_PERMISSION_MODE) andDEEPSEEK_*(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()rejectsnull— acceptsundefinedonly. When the frontend builds a request body withJSON.stringify, an explicitnullfield is preserved on the wire and fails validation withINVALID_INPUT. Convertnull→undefinedbefore 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
totalRowsinRenderParams; 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 shrinkmain's padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar areposition: 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-inputis single-source — BOTH echo addons live ONLY inpackages/xterm-zerolag-input/src/, bundled into TWO gitignored vendor files:vendor/xterm-zerolag-input.js(buffer overlay, entryzerolag-input-addon.ts) andvendor/xterm-predictive-echo.js(codex write-through, entrypredictive-echo-addon.ts) — dev byscripts/postinstall.js, prod byscripts/build.mjs.app.js/terminal-ui.js only consume them vianew LocalEchoOverlay(terminal)/new PredictiveEchoOverlay(terminal); there is no inline copy. So: change the package source, then rerun the bundle step (npm installfor dev,npm run buildfor prod). Never hand-editapp.jsfor 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) withoutCODEMAN_PASSWORDstarts anyway but prints a loud warning;--allow-unauthenticated-network/CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1acknowledges it.⚠️ The production systemd unit passes no--host, so prod binds localhost only: reach it viatailscale serve/tunnel to127.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.shis 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 fromCODEMAN_INSTANCEviasrc/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.$HOMEisolation is NOT enough because tmux is system-global. To run two instances, give each a distinctCODEMAN_INSTANCE(scopes dir + socket together), or setCODEMAN_TMUX_SOCKET+CODEMAN_DATA_DIRindividually;scripts/run-beta.shdoes this for a beta alongside prod. Any new~/.codeman/...path MUST go throughdataPath(), neverjoin(homedir(), '.codeman', …), and any newtmux -Lcaller throughresolveTmuxSocketName()(both inconfig/instance.ts): the TUI shells out to tmux from a second process, and a hardcodedcodemanthere would point a beta instance at prod's panes. → architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger - node-pty's macOS
spawn-helperships without+x(issues #6, #204):node-pty@1.1.0publishesprebuilds/darwin-<arch>/spawn-helperas mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start withError: posix_spawnp failed.Linux can never reproduce it:spawn-helperis anOS=="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 installneeds a C/C++ toolchain there (make,g++,python3), soinstall.shchecks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp withnot found: make. Do not drop that step.⚠️ Look inprebuilds/<platform>-<arch>/, not justbuild/Release/, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletesprebuilds/before compiling):npm run fix:node-ptychmods every helper then proves it by really opening a PTY.spawnPtyWithHelperRepair()(utils/node-pty-repair.ts) wraps everypty.spawn()insession.tsand 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 aworkingDircan sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded asyncstat/lstat/readFileholds one of libuv's threadpool workers (4 by default, shared with everyfs,dns.lookupandcryptocall) until the mount returns. On a request or spawn path useprobePath()/probePathKind()(utils/bounded-path-probe.ts, read its@fileoverview).⚠️ unknownis NEVERabsent: 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 stillunknown. User-facing errors for it go throughdescribeUnknownPath(), so a refused probe is not reported as a broken folder. - Headless screenshots:
deviceScaleFactorMUST 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.mjsmints a timestamped filename per run. Seed the per-devicelocalStoragekeys (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.
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. 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).
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. wait.timedOut). stop/blocked exist for claude and deepseek ONLY (rule lives in hooksAvailableForMode()): explicit request elsewhere is a 400. notifySignal('exit') BEFORE cancelAll(); hangup abort listens on reply.raw (guarded by writableFinished), never req.raw; liveness comes from isPaneDead, never session.pid. 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. 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). 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. --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. 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. 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). --url/CODEMAN_API_URL, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. --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. DELETE /api/cases/:name (the ONE recursive-delete path), and the sweep excludes inUse cases. 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. 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.
❯ 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. 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
✻ 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. isAwaitingWorkers()), never pane-wide and never on the stream. → architecture-invariants#idle-detection-composer-glyph-and-working-line
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. 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. compactScreenText(), all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). \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. TRUST_DIALOG_WINDOW_MS (90s), two-marker match (isTrustDialogScreen), attempt cap TRUST_DIALOG_MAX_ATTEMPTS (6). capturePaneText(); only a direct-PTY session falls back to a SHORT buffer tail. _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). 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. 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. 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. GET /api/settings must never write. 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. 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. classifyRemoteAliveExit), never stdout. buildSshConnectionArgs(); never hand-build one. 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). 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. handleRemoteSessionDropped, boot recovery or cron-service.ts, and GET /api/sessions/:id/reachability must never wake. ServerAliveInterval, and a jumpHost/socksProxy/ProxyCommand host is reachability-UNKNOWN (isProbeable()): never buffer, gate or banner on it. 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. 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. owned AFTER dockerConfigHash. availableModes), and a failed probe is normal for an OWNED case. overlays.docker.rootCommand, never a branch. 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. 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. /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. external, hooks and altScreen independent; never derive one from another. 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. 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. _predictHookOnData must never return (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. approveProjectTrust executes repo code, so it is in the clamp's materialize branch; never wire --api-key. alwaysApprove is stripped for non-granted owners (only-if-sent). 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_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. --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. 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. 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. __customModel), never values (they carry the API key). privilegedEnvKeys, and ANTHROPIC_* stays out of claude's allowedPrefixes. 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
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. 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. <file>.codeman-bak, one apply at a time, else 409). env/headers (secrets) is left 0600, and so is the backup. 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. 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()). 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. 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). ~/.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. 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. 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. role=tree, headers owning role=groups, one roving tabindex=0); the strip, sidebar and flat rail stay tablist/tab. 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). 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(). 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. PUT /api/session-order via tab-layout-legacy-order.ts; change both sides together. 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. restoreMuxSessions(), gated by workspaceHooksEnabled (SYNCED, default ON). applyWorkspaceHooks, never call ensureCodemanHooks at a new site, or the setting silently stops applying. 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. await, single-flighted per owner). rearmAutoResumeSchedule: false, and pass nameSource through. 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). 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. verifyStillAnswerable(); the heuristic working signal alone may resolve idle items only. applyCapture() is ADD-ONLY for options. 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, /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). 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. src/claude-credentials.ts): never refresh them (it rotates the refresh token and can sign the user out of their CLI). voice-pcm-worklet.js borrows voice-input.js's ?v= token, so edit the two together.
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. {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 CUP), so no line-deleting transform may run over it; those skips key on isFullCapture, never on ?full=1 alone. _replayWouldShrinkBuffer()). captureCols/captureRows are absent when no frame was positioned: test Number.isFinite, never truthiness. _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. 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); getClaudeCliVersion() must never cache a FAILED probe. _shouldReportMouseToCli() gates all three report sites on cliMouseTracking (from _recordStrippedMouseMode(), session.ts), or a plain shell prints the reports as literal text. 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. -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. /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. 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). 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. window.open/src) through CodemanBase.url(). 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. 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). 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. 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. 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). 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). _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. _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. 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. 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. 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. /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. 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. CASES_DIR, /mnt/d and CODEMAN_FILE_PICKER_ROOTS, blocks sensitive trees, and rejects symlink escapes after realpath. 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). 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. *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). 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. --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. $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. reply.hijack(), so sendRawStream must copy the status onto reply.raw by hand or a partial body ships as 200. _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. :: form and a leading -, spawn only argv arrays with -- before operands. gitNonInteractiveEnv()) or the open request hangs; never collect credentials, refuse user:password@ URLs. 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. 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>/...). test/webview-auth-exemption.test.ts). allow-same-origin unless trusted, and Authorization/codeman_session are stripped upstream in both modes. webview-egress.ts); use the undici package's own fetch + Agent, never Node's global fetch. 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. 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. {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 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/. 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. 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. 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
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. 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. 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. 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).
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.
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).
- API endpoint: Types in
src/types/domain file, route insrc/web/routes/*-routes.ts. Return theApiResponseenvelope ({ success: true, data }; errors viacreateErrorResponse()with proper status code). Validate with Zod schemas inschemas.ts. - SSE event: Add to
src/web/sse-events.ts+SSE_EVENTSinconstants.js, emit viabroadcast(), handle inapp.js(addListener() - Session setting: Add to
SessionState, include insession.toState(), callpersistSessionState() - App setting: decide per-device vs synced first. Per-device keys go in the
displayKeysset in settings-ui.js and must NOT be added toSettingsUpdateSchema(it is.strict()).⚠️ Anything inPUT /api/settingsthat acts on a setting (thetoggleServicewatcher calls) must resolve frommerged(persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, andbody.x ?? defaultturns every omission into "apply the default" and silently resets live services. Pinned bytest/routes/system-routes-settings-partial-put.test.ts. - Hook event: Add to
HookEventType, add hook inhooks-config.ts:generateHooksConfig(), updateHookEventSchema - 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
WebServeritself binds port 0 (new WebServer(0, …), thenserver.boundPort), and a rawhttp/net/Fastify/wsserver listens on0and readsaddress().port; mobile tests get theirs fromcreateTestServer()and readboundPorttoo.test/test-ports-guard.test.tsrefuses a fixed port in either. Route tests useapp.inject()(no port needed); seetest/routes/_route-test-utils.ts.
Validation: Zod v4 (different API from v3). Define schemas in schemas.ts, use .parse()/.safeParse().
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
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 nameThree 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 defaultnpm 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.
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). safeRmHomeTree(). 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).
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.
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 stateLegacy 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).
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.
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()). start_command_hint/export_bind_env, and call stop_background_helpers before exec. --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). declare -A, mapfile, namerefs, ${x,,}, here-strings, empty-array expansion under set -u). 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.