CLAUDE.md loads into every session, and its Architecture section had grown feature write-ups (history, measurements, rationale) that belong in docs/architecture-invariants.md per the file's own header. Each long block now keeps what the feature is, where it lives, its setting/default and the rules that prevent real bugs, and links to its invariants section. Everything removed was moved there: 29 new sections, extra facts appended to the existing ones. Also: hard-coded counts (SSE events, route handlers, module/file counts, device profiles) replaced by pointers to the source of truth, and the Debugging commands fixed to use the codeman tmux socket and HTTPS for prod. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
139 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Deep implementation detail lives in
docs/architecture-invariants.md. This file holds the rules that prevent mistakes; that file holds the mechanisms, file inventories, and the history behind each rule. Pointers below are written as→ architecture-invariants#anchor. When the goal is raw throughput,docs/SPEEDRUN.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.
Quick Reference
| Task | Command |
|---|---|
| Dev server | npm run dev (or npx tsx src/index.ts web) |
| Type check | npm run typecheck (= tsc --noEmit) |
| Lint | npm run lint (fix: npm run lint:fix) |
| Format | npm run format (check: npm run format:check) |
| Tests | npm test (the CI gate — safe to run bare) · one file: npm test -- test/<file>.test.ts · see Testing for the excluded suites |
| Build | npm run build (esbuild via scripts/build.mjs, NOT tsc — tsc --noEmit is type-check only) |
| Production | npm run build && systemctl --user restart codeman-web |
CRITICAL: Session Safety
You may be running inside a Codeman-managed tmux session. Before killing ANY tmux or Claude process:
- 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).
CRITICAL: Always Test Before Deploying
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.
COM Shorthand (Deployment)
Uses Semantic Versioning (MAJOR.MINOR.PATCH) via @changesets/cli. What SemVer actually covers (the CLI, documented env vars, and the HTTP/SSE API under /api/v1: endpoint paths, response envelope, errorCode values and SSE event names are public/stable; on-disk state, internal TS modules, and experimental features are internal/unstable) is defined in docs/versioning-policy.md. Third-party integration surfaces are documented in docs/extending-codeman.md. Security reporting + known limitations live in .github/SECURITY.md.
When user says "COM":
-
Determine bump type:
COM= patch (default),COM minor= minor,COM major= major -
Create a changeset file (no interactive prompts). Write a
.mdfile in.changeset/with a random filename:cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET' --- "aicodeman": patch --- Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag) CHANGESETReplace
patchwithminorormajoras needed. Include"xterm-zerolag-input": patchon a separate line if that package changed too. -
Consume the changeset:
npm run version-packages(auto-bumpspackage.jsonfiles, updatesCHANGELOG.md, runsnpm install --package-lock-only, and verifies lockfile sync viascripts/check-lockfile-sync.mjs— all in one command; never hand-editCHANGELOG.mdorpackage-lock.jsonversions) -
Sync CLAUDE.md version: Update the
**Version**line below to match the new version frompackage.json -
Commit and deploy: verify the branch first (
git branch --show-current), then stage EXPLICIT paths — nevergit add -A, which has swept another session's WIP into a release.git status --shortand account for every line before committing:git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web -
Refresh the getcodeman.com version badge: the landing page's status bar carries the release version (
v<x.y.z> · getcodeman.com · MIT), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip andsitemap.xmlrefresh that belong in the same pass. Poll production (curl -s https://getcodeman.com/ | grep v<x.y.z>) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so. -
Wait for CI: after
git push, TWO workflows fire per master push —CIandRelease(the npm publish + GitHub release). List both runs for the pushed commit withgh run list --commit $(git rev-parse HEAD) --json databaseId,workflowNameand watch EACH withgh run watch <id> --exit-status. Confirm both pass before considering the release done (gh run list -L 1returns only one of the two). -
Announce it in Discussions: every release gets a post in the Announcements category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with
gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'(the repo's node id and its Announcements category id;pinDiscussiondoes not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
CI runs npm run check:lockfile on every push/PR, so lockfile drift fails the build even if the version-packages script is bypassed.
Version: 1.32.0 (must match package.json)
Project Overview
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
Tech Stack: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (agy, Google), Pi (pi.dev), Grok Build (grok, xAI), DeepSeek Harness (dsh) and OMP (omp) CLIs via pluggable CLI resolvers (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp').
TypeScript Strictness (see tsconfig.json): noUnusedLocals, noUnusedParameters, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch, allowUnreachableCode: false, allowUnusedLabels: false.
Requirements: Node.js 22+, Claude CLI, tmux
Git: Main branch is master. Terminal session dashboard: codeman tui (--list to list, codeman tui <n> to attach).
Additional Commands
npm run dev = dev server. Default port: 3000 (override with --port or the CODEMAN_PORT env var). To run this beta isolated alongside a prod Codeman, use scripts/run-beta.sh (sets CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000). Commands not in Quick Reference:
| Task | Command |
|---|---|
| Terminal dashboard | codeman tui (--list prints the numbered list and exits, codeman tui <n> attaches to row n; both short-circuit before any screen setup). Needs a TTY; without a server it starts attach-only. docs/tui.md |
| Dev with TLS | npx tsx src/index.ts web --https |
| Override window title hostname | npx tsx src/index.ts web --title-hostname <name> (default: os.hostname() — codeman:<name> is used for tab title, title-flash, and OS desktop notification prefix) |
| Bind a non-loopback host | npx tsx src/index.ts web --host 0.0.0.0 (or -H; env CODEMAN_HOST; default 127.0.0.1). Without CODEMAN_PASSWORD it starts but warns loudly — see Common Gotchas + docs/security-architecture.md |
| Mount under a reverse-proxy sub-path | npx tsx src/index.ts web --base-url /codeman (env CODEMAN_BASE_URL; default /). Normalized in src/config/base-path.ts ('' = root). See Reverse-proxy base path below + docs/wiki/Remote-Access.md |
| Continuous typecheck | tsc --noEmit --watch |
| Watch-mode test | npm run test:watch -- test/<file>.test.ts (runs the CI gate's config; pass a file to narrow it) |
| Test coverage | npm run test:coverage |
| Dead-code sweep | npm run knip (config in config/knip.json, passed via --config) |
| Rebuild gesture overlay | npm run build:gesture (esbuild packages/gesture-control/src/codeman/entry.ts → src/web/public/gesture/gesture-codeman.js; commit the result) |
| 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 |
| Gesture playground | npm run dev in packages/gesture-control/ (standalone vite demo, fake tabs) |
| Check public-asset formatting | npm run check:public-assets (prettier-checks src/web/public/** text assets; scripts/check-public-assets.mjs) |
| Frontend JS syntax check | npm run check:frontend-syntax (scripts/check-frontend-syntax.mjs; runs in CI) |
| Excluded-suite runners | npm run test:browser · npm run test:mobile · npm run test:perf · npm run test:all (everything, environmental failures included) — see Testing |
| Production start | npm run start |
| 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 |
| Install/remove the service | codeman service install / status / uninstall (systemd user unit on Linux, LaunchAgent on macOS; names from config/service-names.ts) |
| Dependency doctor | codeman doctor (alias check-deps; --json, --category core|office|other). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against config/dependency-registry.ts; engine is pure given an injectable ProbeHost |
| Multi-user accounts | codeman users add <name> / passwd <name> / list / rm <name> (writes ~/.codeman/users.json, mode 0600; see Multi-user mode) |
CI: .github/workflows/ci.yml (push to master/main + PRs, Node 22) runs two jobs: (1) check:lockfile, typecheck, lint, check:frontend-syntax, format:check, then a server boot smoke test (tsx src/index.ts web --port 3151 must answer /api/status within 30s); (2) the unit/integration test suite via npm run test:ci (config/vitest.ci.config.ts — excludes the browser-driven test/mobile/** suite, perf-* benchmarks, and 9 Playwright tests; 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/).
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, upload.html, and 15 hand-formatted modules) plus CLAUDE.md. Those files are hand-formatted by design; npm run check:public-assets and check:frontend-syntax are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
Common Gotchas
- Single-line prompts only —
writeViaMux()sends text+Enter separately; multi-line breaks Ink. ⚠️ Input must END with\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 - 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 - Model choice flows via
settings.local.json, NOT--modelor 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 - 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 - 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.
Architecture
Core Files (by domain)
| Domain | Key files | Notes |
|---|---|---|
| Entry | src/index.ts, src/cli.ts, daemon-control, service-installer, config/service-names, cli-style |
The last three back web -d / service install; cli-style is the shared palette/table/spinner/confirm kit |
| TUI | src/tui/: tui-app ★ + tui-client (the only IO) over a pure core (-model, -layout, -render, -keys, -ansi, -composer, -approvals, -digest, -sse, -types) |
codeman tui, a CLIENT of the server, never a second brain. Design doc: docs/tui-plan.md; user guide docs/tui.md |
| DeepSeek | src/utils/deepseek-cli-resolver.ts, src/deepseek-status-shim.ts, src/deepseek-web-server.ts (background dsh web, not a session) |
dsh is a PROFILE LAUNCHER, not an agent; read docs/deepseek-integration.md first |
| Session | src/session.ts ★, session-manager, session-auto-ops, session-cli-builder, session-task-cache, session-order (pure), session-pty-exit-breaker, session-trust-dialog (pure), usage-limit-patterns, usage-telemetry; src/services/unified-session-service.ts |
Pure/unit-tested helpers are split out of session.ts on purpose |
| Mux | src/mux-interface.ts, src/mux-factory.ts, src/tmux-manager.ts ★, src/proc-tree.ts (pure, bounded descendant walk) |
|
| Respawn | src/respawn-controller.ts ★ + 4 helpers (-adaptive-timing, -health, -metrics, -patterns) |
Read docs/respawn-state-machine.md first |
| Ralph | src/ralph-tracker.ts ★, src/ralph-loop.ts + 5 helpers (-config, -fix-plan-watcher, -plan-tracker, -stall-detector, -status-parser) |
Read docs/ralph-wiggum-guide.md first |
| Orchestrator | src/orchestrator-loop.ts, -planner, -verifier |
Read docs/orchestrator-loop-architecture.md first |
| Cron | src/cron/cron-service.ts, cron-time.ts (pure next-run math), cron-input.ts |
Read docs/cron-discovery.md first. Distinct from legacy ScheduledRun (/api/scheduled) |
| Agents | src/subagent-watcher.ts ★, team-watcher, bash-tool-parser, transcript-watcher, workflow-run-watcher |
workflow-run-watcher is STANDALONE and never touches subagent-watcher |
| AI | src/ai-checker-base.ts, ai-idle-checker.ts, ai-plan-checker.ts |
|
| Tasks | src/task.ts, task-queue.ts, task-tracker.ts |
|
| State | src/state-store.ts, run-summary.ts, session-lifecycle-log.ts, intent-store.ts, tab-layout.ts (pure model) + -service (sole mutation boundary) + -persistence + -legacy-order |
|
| Infra | src/hooks-config.ts, push-store, tunnel-manager, image-watcher, file-stream-manager, remote-hosts + remote-reconnect + remote-wake (IO: dgram/net/child_process), docker-hosts + docker-export |
Remote/docker case overlays; see Key Patterns |
| Web tabs | src/webview-store.ts, webview-capabilities.ts, src/web/webview-proxy.ts (pure), src/web/routes/webview-routes.ts |
Dashboard URLs as tabs; NOT a SessionMode |
| Search | src/search-service.ts |
Pure in-memory core for GET /api/search |
| Attachments | src/attachment-registry.ts, attachment-magic, generated-artifact-attachments, session-attachment-history, document-preview-cache, document-thumbnailer, document-conversion-limiter, config/attachment-guard |
See Key Patterns |
| Plan | src/plan-orchestrator.ts, src/prompts/*.ts, src/templates/ (claude-md.ts + case-template.md) |
templates/ holds the CLAUDE.md scaffold generated into new cases |
| Web | src/web/server.ts ★, sse-events.ts, routes/*.ts (one module per domain + barrel; session-routes.ts ★), route-helpers.ts, ports/*.ts, middleware/auth.ts, schemas.ts, self-update.ts, plan-usage-latest.ts, ws-connection-registry.ts, heic-jpeg-converter.ts + heic-jpeg-worker.ts |
|
| Frontend | src/web/public/app.js (core) + the modules listed in the Frontend load order + sw.js (+ voice-pcm-worklet.js, fetched from JS, not in the load order) |
See Frontend section for the load order, which is authoritative |
| Types | src/types/index.ts (barrel) → domain files; also src/types.ts root re-export |
See @fileoverview in index.ts |
★ = Large, central file (>50KB) — read its @fileoverview first. All files have @fileoverview JSDoc — read that before diving in. Discovery aid: grep -l '@fileoverview' src/web/routes/*.ts lists all route modules; same grep works for src/types/, src/web/public/*.js.
Local packages: packages/xterm-zerolag-input/ (local echo overlay, single-source, see Gotchas). packages/gesture-control/ (codeman-gesture-control, hand-tracking overlay source, built via npm run build:gesture).
Config: src/config/ — flat files plus the cli-registry/ subdir, no barrel (index.ts) exists; import from the specific file. ⚠️ There are TWO config/ directories: the repo-root config/ holds tooling only (ESLint, knip, the vitest configs, test-suites.ts), while runtime config lives in src/config/. Throughout this file a bare config/<name>.ts in a code context means src/config/<name>.ts.
Utilities: src/utils/ — re-exported via index. Key: CleanupManager, LRUMap (⚠ NOT in the barrel — import from ./utils/lru-map.js directly), StaleExpirationMap, BufferAccumulator, stripAnsi, Debouncer, KeyedDebouncer. Also: claude-cli-resolver/opencode-cli-resolver/codex-cli-resolver/gemini-cli-resolver/antigravity-cli-resolver/pi-cli-resolver/grok-cli-resolver/deepseek-cli-resolver/omp-cli-resolver (CLI path resolution, one per SessionMode, all nine sharing the lookup chain in cli-executable-resolver: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ pi-, grok- and deepseek-cli-resolver additionally probe the binary's identity, since pi is a generic name, grok has npm squatters, and Debian ships an unrelated dsh), file-query (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), string-similarity (fuzzy matching), regex-patterns (ANSI/token/spinner patterns), assertNever (exhaustive checks), token-validation (auth tokens), nice-wrapper (process priority), shell-resolver (⚠ resolves a real login shell for mode: 'shell'; the literal string $SHELL used to be expanded by the SERVER's shell, which is empty in a container), event-loop-monitor (a sync execSync freezes the port while the process stays alive, leaving no trace), dependency-checker + dependency-report (the codeman doctor probe engine, registry in config/dependency-registry.ts).
Data Flow
- Session spawns
claude --dangerously-skip-permissionsvia node-pty - PTY output buffered, ANSI stripped, parsed for JSON messages
- WebServer broadcasts to SSE clients at
/api/events - State persists to
~/.codeman/state.jsonvia StateStore
Key Patterns
Input: session.writeViaMux() for programmatic/curl input via tmux send-keys -l + send-keys Enter, single-line only. Interactive browser input goes through a durable exactly-once layer: a stable clientId + monotonic per-session seq persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. ws-connection-registry.ts supersedes only same-TAB reconnects, so two tabs on one session coexist. → architecture-invariants#input-delivery-and-ws-resilience
Agent wait primitives: bounded long-polls: GET /api/sessions/:id/wait, GET .../wait-output (literal substring, never regex) and wait/waitTimeout on POST .../input. Registry session-wait-registry.ts (pure), bounds config/agent-wait.ts. ⚠️ A timeout is a 200 (wait.timedOut). ⚠️ stop/blocked exist for claude and deepseek ONLY (rule lives in hooksAvailableForMode()): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must notifySignal('exit') BEFORE cancelAll(); hangup abort listens on reply.raw (guarded by writableFinished), never req.raw; liveness comes from isPaneDead, never session.pid. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or wait-output markers. Packaged as the skills/codeman skill (codeman skill install, plugin marketplace, or injection behind agentSkillEnabled, SYNCED, default OFF): 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-created case marker (src/agent-case-marker.ts): a case dir that POST /api/quick-start CREATES for an agent-driven spawn (signal: the preamble's X-Codeman-Agent-Origin header / agentOrigin field, else a resolved parentSessionId) gets .codeman-agent-case.json, published as agentCreated on GET /api/cases; GET /api/cases/agent-created is the cleanup listing (inUse, modifiedAt) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on DELETE /api/cases/:name (the ONE recursive-delete path), and the sweep excludes inUse cases. ⚠️ Changing the preamble's headers requires bumping CODEMAN_PREAMBLE. → architecture-invariants#agent-created-case-marker
Agent preamble cache GC: the §0 preamble seeded per claude session ($XDG_CACHE_HOME/codeman-agent-<id>.sh) is now REMOVED with the session (removeAgentSessionPreamble from _doCleanupSession, killMux only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (pruneAgentSessionPreambles(this.sessions.keys()), once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and AGENT_PREAMBLE_MAX_AGE_MS (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in test/agent-skill.test.ts.
Idle detection: Multi-layer (completion message → AI check → output silence → token stability). See docs/respawn-state-machine.md.
⚠️ A ❯ sighting is NOT the end of a turn, and neither is silence. Claude redraws the composer all through a turn, and its working line (✻ Actualizing… (13m 23s · …)) is invisible to SPINNER_PATTERN, keyword lists and the raw stream. _confirmIdle() (session.ts) requires the pane to go quiet AND the SCREEN (capturePaneText() + the working-line pattern) to agree; a sustained run of repaints (session-activity.ts) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (capabilities.workDetect), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ workingLine is config-supplied and runs on the PTY hot path, so it must compile through compileVersionRegex() in BOTH the schema refine and _workingLinePattern() (ReDoS guard; null, not throw). → architecture-invariants#idle-detection-composer-glyph-and-working-line
Workspace-trust dialog auto-accept (session-trust-dialog.ts, pure): Claude Code's per-directory trust dialog is always answered yes, or the session is stuck. ⚠️ Match the compacted SCREEN (compactScreenText(), all whitespace removed), never the stream (tmux sends words joined by cursor-forwards, not spaces). ⚠️ Never answer with a blind \r (newer versions highlight "No, exit" first): trustDialogNextKey() returns ONE key per re-read frame (arrow, then Enter only once ❯ is on the trust option), and the LAST marked option wins. ⚠️ All three guards must hold: startup window TRUST_DIALOG_WINDOW_MS (90s), two-marker match (isTrustDialogScreen), attempt cap TRUST_DIALOG_MAX_ATTEMPTS (6). ⚠️ Read capturePaneText(); only a direct-PTY session falls back to a SHORT buffer tail. ⚠️ The scan must schedule its own next read (_trustDialogTimer, cleared in _clearAllTimers()), not rely on PTY output. → architecture-invariants#workspace-trust-dialog-auto-accept
Process-tree walks are bounded (proc-tree.ts, pure): collectDescendants(pid, byParent) is the ONE descendant traversal, fed by one cached ps -eo pid=,ppid= snapshot (refreshProcSnapshot() in tmux-manager.ts: async, in-flight-shared, and ANY error discards the result rather than caching a truncated ps). ⚠️ Never walk a process tree with per-node pgrep or unbounded recursion (the unbounded version took a machine down): the walk must terminate on cycles, cap depth (PROC_WALK_MAX_DEPTH) and node count (PROC_WALK_MAX_NODES), and never spawn anything. ⚠️ Keep it in its own module so the test exercises the shipped code, and report truncation through onTruncated naming both caps, never silently. → architecture-invariants#process-tree-walks-are-bounded
Auto-resume on usage limit (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, usage-limit-patterns.ts (pure, unit-tested) parses the reset time and SessionAutoOps arms a timer for reset+2min, then sends Esc + continue. ⚠️ Respawn cycles are blocked while paused (isLimitPaused guard in onIdleDetected), which is what prevents /clear from wiping the paused conversation. Claude-mode only. → architecture-invariants#auto-resume-on-usage-limit
Plan-usage chip (showPlanUsageLimits, per-device: desktop default ON, handhelds OFF): resolve DISPLAY only through planUsageChipEnabled() (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by readPlanUsageTelemetryEnabled() (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; GET /api/settings must never write. ⚠️ A save sends showPlanUsageLimits ONLY when it flips the chip on that device (planUsageCollectionFlip()), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL claude --settings flag (resolveStatusLineCliCommand), never written to disk, WRAPPING a user's own statusLine, posting to POST /api/status-telemetry. Codex comes from a read-only account/rateLimits/read poll (main bucket only). → architecture-invariants#plan-usage-chip-statusline-telemetry, docs/usage-limits-display-plan.md
Orchestrator: State machine that turns a user goal into a phased plan and drives it to completion: idle → planning → approval → executing → verifying → (replanning) → completed/failed. OrchestratorLoop (engine) delegates plan generation to orchestrator-planner and per-phase verification gates to orchestrator-verifier, executing phases via team agents/task-queue. State persists under the orchestrator key in state.json. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See docs/orchestrator-loop-architecture.md.
Cron (CronJobs): saved, named jobs on a recurring schedule (once/interval/daily/weekly) with per-job run history. ⚠️ Distinct from the legacy ScheduledRun (/api/scheduled, a run-now duration-bounded loop); the two never interact and keep separate Scheduled* / Cron* names. CronService reuses the existing session layer rather than rebuilding tmux logic. Next-run math is pure and unit-tested in cron-time.ts (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → architecture-invariants#cron-jobs, docs/cron-discovery.md
Remote sessions + remote SSH cases: a case can point at a remote host. The agent runs in a durable remote tmux -L codeman-remote, fronted by a LOCAL tmux pane running ssh. Attached (owned:false) sessions detach, never kill; owned ones propagate kill-session. Auto-reconnect (remoteAutoReconnect, default ON) revives ONLY when remoteTmuxSessionAlive() proves the remote session alive. ⚠️ Classify that probe by EXIT STATUS (classifyRemoteAliveExit), never stdout. ⚠️ Command-injection surface: every ssh command line must flow through buildSshConnectionArgs(); never hand-build one. ⚠️ Remote file reads (src/remote-files.ts, attachment routes too) take browser paths only as shellescaped tokens, resolve symlinks fail-closed, cap on the REMOTE size, never copy to local disk, are bounded by src/remote-ssh-limiter.ts, and pick the host from the SESSION, never the path; no writes over ssh (the PUT guard must precede local path validation). ⚠️ Route remote cases through POST /api/quick-start, not POST /api/sessions. → architecture-invariants#remote-sessions-over-ssh, #remote-ssh-cases, docs/remote-sessions.md
Wake-on-LAN (remote-wake.ts): optional RemoteHost.wakeMac (magic packet) or RemoteHost.wakeCommand (single executable, no shell, takes precedence) lets HTTP input, POST /api/sessions/:id/wake and the user's create/attach (ensureHostAwake) wake a sleeping host. ⚠️ Only an explicit user request may wake: never give the registry to the auto-reconnect watcher, handleRemoteSessionDropped, boot recovery or cron-service.ts, and GET /api/sessions/:id/reachability must never wake. ⚠️ Detection is a throttled bare TCP probe; never add ServerAliveInterval, and a jumpHost/socksProxy/ProxyCommand host is reachability-UNKNOWN (isProbeable()): never buffer, gate or banner on it. ⚠️ In multi-user mode a non-admin attach 403s BEFORE host lookup. ⚠️ WS keystrokes bypass the registry, so the banner (host-wake-ui.js) must not promise queued input. Waiting requests use the 40 s REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS. → architecture-invariants#remote-ssh-cases
Docker cases: a case can point at a container running any CLI mode inside it, a LOCATION OVERLAY on cases, never a SessionMode. One long-lived container per case, shared by its sessions: killing a session kills only its in-container tmux, never docker stop while siblings remain. The workspace is bind-mounted at the same absolute path. Credentials are seeded, never shared RW. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. A drifted config (label hash) REFUSES the launch. ⚠️ An adopted container (DockerCase.owned === false) is only execed into: never create, start, stop, restart, remove, docker commit or docker pause it; fail closed. Test owned === false, never truthiness. ⚠️ Apply owned AFTER dockerConfigHash. ⚠️ Run modes come from the CONTAINER (availableModes), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's overlays.docker.rootCommand, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs CODEMAN_DOCKER_BRIDGE_HOOKS=1 for in-container hooks. → architecture-invariants#docker-cases, docs/docker-cases.md
Docker Compose deployment (docker/): Codeman runs in a container and spawns Docker cases as SIBLING containers via the host socket, never nested. resolveDockerDaemonMountSource() maps HOME bind sources into the daemon's namespace (CODEMAN_DOCKER_HOST_HOME); CODEMAN_CASES_PATH makes workspaces resolve to the same absolute path on both sides. ⚠️ CODEMAN_CASES_PATH must move every consumer: resolve it only via config/cases-dir.ts. ⚠️ .dockerignore matches whole paths: keep **/.env or docker/.env secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: Start-Codeman.sh pre-creates them, and docker/entrypoint.sh (root, cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID] against cap_drop: ALL, KILL for tini; pinned by the test) fixes ownership then drops to PUID:PGID via setpriv, never re-owning foreign dirs. ⚠️ Append /opt/codeman-cli to PATH, never prepend. ⚠️ server.Dockerfile, the compose file and .env.example feed the self-updater's environment gate (docs/docker-self-update.md). → architecture-invariants#docker-compose-deployment, docs/docker-compose.md
CLI registry (src/config/cli-registry/): every run mode is a CliEntry (discovery, launch argv template, env handling, capabilities, and the overlays behind remote/docker pane commands). No code outside stock.ts may branch on a CLI id: use a capability field or a NAMED PROFILE (profiles.ts); test/cli-registry-no-id-branching.test.ts and test/frontend-cli-no-id-branching.test.ts enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep external, hooks and altScreen independent; never derive one from another. ⚠️ Config regexes (discovery.version.regex, capabilities.workDetect.workingLine) must compile through compileVersionRegex(). ⚠️ privilegedParams[].param names a LAUNCH PARAM, not the legacy <Mode>Config field (bridged only by launch.legacyConfigAliases); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of buildRemoteLaunchCommand are not covered by the pane-command golden. ~/.codeman/clis.json overrides entries (read-only). → architecture-invariants#cli-registry, docs/cli-registry.md
External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP): isExternalCliMode() in session.ts gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI capabilities.workDetect data, not this gate. All eight require tmux, no direct PTY fallback (secrets go via socket-scoped tmux setenv, never the command line). ⚠️ run*() in session-ui.js MUST unwrap the {success,data} envelope. ⚠️ Codex uses predictive write-through echo, never the buffer overlay: _predictHookOnData must never return (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ Pi: no bypass flag, never invent one; approveProjectTrust executes repo code, so it is in the clamp's materialize branch; never wire --api-key. ⚠️ Grok: alwaysApprove is stripped for non-granted owners (only-if-sent). ⚠️ DeepSeek: the agent is a PROFILE (Run gates on isDeepSeekRunnable()); the permission switch is the DSH_PERMISSION_MODE env var, so clampEnvOverridesForOwner() must DROP DSH_PERMISSION_MODE, DSH_HOME and DEEPSEEK_BASE_URL for non-granted owners; hooksAvailableForMode() is per-SESSION for it (pass sessionHookOptions(session)) and is never a stand-in for mode === 'claude'; answers come from deepseek-transcript.ts, paired by header cwd + boot window, never newest-mtime. ⚠️ OMP: OMP_AUTH_BROKER_URL/_TOKEN are clamped the same way. → architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp
DeepSeek web UI (POST/GET/DELETE /api/deepseek/web, deepseek-web-server.ts): the Run menu's "DeepSeek web UI..." entry supervises ONE background dsh web child process, deliberately NOT a shell session. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (--trusted-host, last asker wins), killed on shutdown via stopDeepSeekWeb() (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both POST and DELETE must stay behind canUsernameRunPrivilegedCommands (booting a profile runs its plugin code; the server is shared). → architecture-invariants#deepseek-web-ui
Custom Model Endpoint Profiles (opt-in, customModelEndpointsEnabled, SYNCED, default OFF; docs/custom-model-endpoints.md): points a session at a user-configured OpenAI-compatible endpoint (store custom-model-hosts.ts, ~/.codeman/custom-model-hosts.json, 0600). authStyle is bearer or api-key, never both headers (hangs the server). The per-CLI redirect is registry data, capabilities.customModelInjection (env / configContentEnv / configDir / unsupported), computed by the pure custom-model-injection.ts; ⚠️ configDir writes an isolated per-session config, NEVER the user's real ~/.codex/~/.pi/~/.omp/grok config. ⚠️ Claude applies via Session.restartCli(), the rest one-shot at launch; retired env keys must also be setenv -u'd (_pendingEnvUnsets), since tmux env survives respawn-pane. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (__customModel), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's privilegedEnvKeys, and ANTHROPIC_* stays out of claude's allowedPrefixes. ⚠️ The Run-menu picker builds entries from window.__codemanCustomModelClis (escaped via escapeScriptJson()), never a hardcoded CLI id list, and launches through run() via a temporary _runMode swap, never setRunMode(). → architecture-invariants#custom-model-endpoint-profiles
⚠️ llama-swap endpoints (one model at a time): the apply routes check GET /running and return requiresConfirmation before evicting a model another live session uses; confirmedSwap and confirmedContext are SEPARATE flags and must stay so. Claude alone gets a context floor (CLAUDE_MIN_SAFE_CONTEXT_TOKENS); context is parsed from /running's cmd, never trusted from /props. Backend log lines come from llama-swap's /api/events upstream source, never /logs. → architecture-invariants#custom-model-endpoint-profiles
Run launch synchronization: the Run entrypoint holds an in-flight lock and disables #runBtn for the whole launch (≥500ms) so a double click cannot create duplicate w<n>-<case> sessions; _ensureCreatedSessionVisible() runs before selectSession() and _onSessionCreated() stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ Closing has the mirror-image race: closeSession() must read wasActive BEFORE its await and announce the delete via _closingSessions, and _onSessionDeleted skips the active-session handoff for ids in that set; never read activeSessionId after the fact. The fallback picks the first sessionOrder entry still in sessions. Tests: test/session-close-fallback.test.ts. → architecture-invariants#run-launch-synchronization
Session lineage lines (tab → tab it spawned, sessionLineageLines, per-device, desktop default ON): a create request may name its spawner via a parentSessionId body field or the X-Codeman-Parent-Session header; resolveParentSessionId() (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides toState(), no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (_appendLineageConnectionLines at the tail of _updateConnectionLinesImmediate()), geometry pure in computeLineagePath(): one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep data-agent-id="lineage:<childId>" (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → architecture-invariants#session-lineage-lines-tab--tab-it-spawned
Auto-named sessions (autoNameSessions, SYNCED, default OFF): a placeholder tab (w3-myapp) takes its first real prompt as a title in the <prefix>: <title> form, so the case identity and w<n> counter survive. Ownership is SessionState.nameSource (placeholder | auto | manual; the name setter / PUT /api/sessions/:id/name makes it manual, never touched again). ⚠️ applyAutoName() flips to auto even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: SessionWriteOptions.fromUser is set by the browser WS path and POST /api/sessions/:id/input ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call trackUserInput()). ⚠️ The pure tracker (session-auto-name.ts) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. Tests: test/session-auto-name.test.ts. → architecture-invariants#auto-named-sessions-first-prompt--tab-title
Maintainer bot (external): the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at scripts/pr-bot/. It moved OUT of this repository on 2026-09-14, to ~/codeman-cases/prbot/ (its own private git repo, systemd unit codeman-pr-bot, guide + agent rules in its own README.md and CLAUDE.md). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named prbot-<n> / dscbot-<n> on the local Codeman and holds clones under ~/.codeman/pr-bot/, so those session names and that data dir are taken; it also fetches PR heads into refs/pr-bot/* of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
Unified session list: GET /api/sessions/unified merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core src/services/unified-session-service.ts), backing the Cmd+K Session Manager, pinning and cross-device tab order (PUT /api/session-order, src/session-order.ts). ⚠️ Transcript history is THREE stores (~/.claude/projects, ~/.omp/agent/sessions, ~/.codex/sessions), folded via the claudeSessionId → Codeman id alias map (not Claude-only despite the name). ⚠️ resumeId is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → architecture-invariants#unified-session-list-and-session-manager
Owner tab layouts (tab-layout*.ts + GET/PUT /api/tab-layout): named tab GROUPS over the flat strip, scoped per owner (@single when multi-user is off), persisted as tabLayouts in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ TabLayoutService is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto PUT /api/session-order via tab-layout-legacy-order.ts; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (markRestorationComplete/assertDeletionReady()): a failed restore must leave the layout untouched or live tabs get pruned. → architecture-invariants#owner-tab-layouts
Hook events: Claude Code hooks trigger via /api/hook-event (permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, idle_prompt, stop, teammate_idle, task_completed, prompt_submitted); see src/hooks-config.ts and docs/claude-code-hooks-reference.md. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from restoreMuxSessions(), gated by workspaceHooksEnabled (SYNCED, default ON). ⚠️ Route that decision through applyWorkspaceHooks, never call ensureCodemanHooks at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as permission_prompt (RED alert), not elicitation_dialog (MCP elicitation). → architecture-invariants#hook-events-and-workspace-hook-installation
Reboot restore (src/reboot-restore.ts pure + web/reboot-restore-registry.ts + routes/reboot-restore-routes.ts + reboot-restore-ui.js): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first await, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, rearmAutoResumeSchedule: false, and pass nameSource through. ⚠️ Undo a failed rebuild with discardPartiallyBuiltSession(), NEVER cleanupSession(). Claude-mode only, never remote/docker. Tests: test/reboot-restore.test.ts. → architecture-invariants#reboot-restore
Approvals Inbox (approvalsInboxEnabled, SYNCED, default OFF; the store and answer endpoints run regardless): web/approval-inbox.ts is an in-memory, claude-only queue fed by /api/hook-event, at most ONE item per session, answered via POST /api/approvals/:id/answer through writeViaMux (menu answers never carry \r). ⚠️ Accept option digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified verifyStillAnswerable(); the heuristic working signal alone may resolve idle items only. ⚠️ applyCapture() is ADD-ONLY for options. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass selectSession(id, { auto: true }); _ackDelivery spends the IDLE alert only. ⚠️ handleInit seeds tab alerts from GET /api/approvals REGARDLESS of the setting. → architecture-invariants#approvals-inbox
Read My Mind intent profiles (readMyMindEnabled, SYNCED, default OFF; docs/readmymind-plan.md): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (transcript:user_prompt), never the input paths; the listener must stay inside startTranscriptWatcher()'s if (!watcher) block. Store src/intent-store.ts → intents.json, ⚠️ written 0600 tmp+rename and never fed to /api/search (prompts carry secrets). Routes in readmymind-routes.ts (ownership via findSessionOrFail WITH req); predictor = pure readmymind-context.ts + IO in readmymind-collectors.ts + readmymind-predictor.ts, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/textContent ONLY and nothing auto-sends, ever. Frontend readmymind-ui.js. → architecture-invariants#read-my-mind-intent-profiles
Voice dictation via Claude (claudeVoiceEnabled, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI /voice backend) instead of Deepgram; the browser captures, src/web/voice-stream.ts relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (src/claude-credentials.ts): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; voice-pcm-worklet.js borrows voice-input.js's ?v= token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → architecture-invariants#voice-dictation-via-claude
Agent Teams: TeamWatcher polls ~/.claude/teams/, matches to sessions via leadSessionId. Teammates are in-process threads appearing as subagents. Enable: CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1. See docs/agent-teams/.
Circuit breakers: the Ralph breaker prevents respawn thrashing (CLOSED → HALF_OPEN → OPEN; reset via /api/sessions/:id/ralph-circuit-breaker/reset). Distinct: the PTY-exit breaker (session-pty-exit-breaker.ts) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit {clearBreaker:true} body on POST /api/sessions/:id/interactive; the frontend's auto-reattach in selectSession() sends no body and must never clear it. → architecture-invariants#circuit-breakers-ralph--pty-exit
Full-scrollback replay: GET /api/sessions/:id/terminal?full=1 returns the whole tmux scrollback ALONE (source='mux-full-history'), superseding the byte buffer. First load of each non-shell TUI session requests it (_fullHistoryLoaded); Shell selection and drop recovery use a bounded 1 MiB ?tail=, and Shell loads the rest only via Load full history, never on ordinary scroll. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never CUP), so no line-deleting transform may run over it; those skips key on isFullCapture, never on ?full=1 alone. ⚠️ A re-pull must never shrink the buffer (_replayWouldShrinkBuffer()). ⚠️ captureCols/captureRows are absent when no frame was positioned: test Number.isFinite, never truthiness. → architecture-invariants#full-scrollback-replay
Split-pane sessions (showSplitButton, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own SplitTerminalPane (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → architecture-invariants#split-pane-sessions
Terminal touch gestures: link taps and text selection: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (_terminalLinkAtPoint), synchronously inside touchend (keeps the user gesture window.open needs) and BEFORE any mouse report; the caret's logical line (_tapIsOnCaretLine) and TUI-owned rows (_isActionableMobileTerminalTap) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap 'input'). ⚠️ Long-press selects via xterm's public select(); keep the three guards: suppress the compat mouse pair after touchend, the bounded focus guard + contextmenu suppression for the platform long-press, and no closing terminal.focus() on phones. Tests: test/terminal-touch-tap.test.ts. → architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection
Auto Copy (copy-on-select) (autoCopySelection, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in onSelectionChange (per-cell); it only arms _autoCopyPending and a document-level mouseup flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from _endTouchSelectionGesture()/_selectTouchSelectionLine() (no mouseup arrives). ⚠️ Unlike copyTerminalSelection(), never clear the selection or focus the terminal; restore prior focus. Guards are pure in decideAutoCopy() (constants.js, 1M-char cap, refused not truncated). Tests: test/terminal-auto-copy.test.ts. → architecture-invariants#auto-copy-copy-on-select
Ctrl+V paste trap (image-input.js): Ctrl+V routes through _handleImagePaste(), which focuses a hidden contenteditable trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through terminal.paste() so bracketed-paste markers survive. ⚠️ The trap must consume exactly ONE paste event (Firefox delivers two per keypress: the execCommand('paste') event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the execCommand('paste') call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: test/image-paste-trap.test.ts. → architecture-invariants#terminal-paste-ctrlv
Terminal scrollback strip + wheel/touch forwarding: codex/claude/gemini get the FULL strip (alt-screen, 3J, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on useMux: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for claude ≥ 2.1.187 ONLY; ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ getClaudeCliVersion() must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: _shouldReportMouseToCli() gates all three report sites on cliMouseTracking (from _recordStrippedMouseMode(), session.ts), or a plain shell prints the reports as literal text. Read _logScrollRouting() before diagnosing a scroll report. → architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding
Detached start + service install: codeman web -d relaunches the same entry script detached:true (setsid); nohup is not what makes it survive. ⚠️ Both -d and service install must REFUSE when a server is already up on this data dir (pidfile + /api/status probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll /api/status until the child answers or dies. --stop must verify the pid still looks like Codeman (ps -o command=) before signalling. Unit/label names live only in config/service-names.ts. service install bakes the installing shell's PATH into the unit and never writes CODEMAN_PASSWORD into it. → architecture-invariants#detached-start-and-service-install
Self-update (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (systemd, launchd, launchd-daemon, docker-compose, else none). The work runs in a DETACHED scripts/self-update.sh writing update-status.json, polled across the restart; pure helpers in src/web/self-update.ts. ⚠️ Compose: the restart kills the script, so nothing may be appended after the restarting marker; the repo must stay a host bind mount over /opt/codeman and the image must keep devDependencies + toolchain. ⚠️ evaluateEnvironmentGate() refuses releases that change server.Dockerfile/docker-compose.yaml or add .env.example keys, re-evaluated on POST /api/system/update; unknowns fail OPEN, but the exit-to-restart needs --restart-by-exit 1 (CODEMAN_RESTART_BY_EXIT=1 only in the Compose file). ⚠️ Keep the agent CLIs in server.Dockerfile pinned. → docs/docker-self-update.md, architecture-invariants#self-update
Reverse-proxy base path (--base-url / CODEMAN_BASE_URL, default /; pure single source src/config/base-path.ts, normalized to '' or /foo): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: stripBasePath() in Fastify's rewriteUrl (routes stay prefix-agnostic; unprefixed requests still answer), one onSend hook rebasing Location, renderIndexHtml rewriting <base href> + injecting window.__CODEMAN_BASE__, and CodemanBase.url() (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/window.open/src) through CodemanBase.url(). ⚠️ Web-tab proxy egress goes through proxyPrefixFor(cap, basePath); ingress parsers stay base-agnostic. ⚠️ --base-url must ride buildWebArgs and resolveServicePlan. Tests: test/base-path.test.ts. → architecture-invariants#reverse-proxy-base-path
Attachments (live external document references; all wiring in file-routes.ts): a registry maps a stable attachmentId to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The magic-link scanner (codeman://attach?... in terminal output) is prompt-injectable, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension allowlist, not a blocklist. document-conversion-limiter.ts caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → architecture-invariants#attachments
File-path links (terminal + chat): a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (FILE_PATH_LINK_PATTERN / absoluteFilePathPattern() in constants.js) feeds the xterm link provider AND _linkifyFilePaths(), a fresh instance per call (lastIndex). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (POST /api/sessions/:id/attachments with notify: false), never by widening file-content/file-raw or file-stream-manager's tail -f allowlist. ⚠️ TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (never a second list), and widening READ must never widen RUN: html/htm/svg stay download-only, other text is inert text/plain+nosniff. Media extensions are single-sourced in attachment-registry.ts. → architecture-invariants#file-path-links-terminal--response-viewer
Filesystem path picker (Link Existing "Browse" + the mobile keyboard's 📁 Path key): lazy one-directory browsing via GET /api/filesystem/browse, with GET /api/filesystem/preview for the tapped file. Inserts the path without Enter, so the prompt is never submitted; the sibling ⌫ All key clears only the unsent prompt and must never send the agent's /clear. ⚠️ This is a second file-serving surface and inherits neither the attachment confinement nor its ownership scoping — it allowlists Home, CASES_DIR, /mnt/d and CODEMAN_FILE_PICKER_ROOTS, blocks sensitive trees, and rejects symlink escapes after realpath. ⚠️ The optional sessionId is an ownership boundary that must be canAccessOwned-checked by hand (it does not go through findSessionOrFail), and in multi-user mode a non-admin gets only their own userSpacePath as a root: per-user spaces live INSIDE homedir(), so a Home root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert text/plain. → architecture-invariants#filesystem-path-picker
File Viewer edit mode (issue #212): the file-preview overlay edits workspace text files in place — GET .../file-content?edit=1 + PUT /api/sessions/:id/file-content, policy in src/config/file-editing.ts. This is a third file surface and the only one that WRITES: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/.git denies and an extension allowlist; writes are wx-temp + rename (no O_CREAT anywhere = edit-in-place is structural); optimistic concurrency via sha256 baseHash → 409. ⚠️ edit=1 never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → architecture-invariants#file-viewer-edit-mode, docs/file-viewer-edit-plan.md
Files panel search (COD-236, the q param on GET /api/sessions/:id/files): compileFileQuery() (utils/file-query.ts, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (MAX_QUERY_LENGTH, 256) query compiles to null, keeping the default tree response byte-identical. ⚠️ Never compile a glob into a RegExp (*a*a*a… backtracks and freezes the event loop for the whole server): globMatch() is a two-pointer wildcard walk. → architecture-invariants#files-panel-search
Raw file bodies are streamed and range-aware: file-raw, the attachments /raw route and GET /api/download share sendFileBody(), advertise Accept-Ranges: bytes and answer Range with 206 + Content-Range (single-range, parser in src/web/http-range.ts); without it <video> cannot seek. The size cap (MAX_FILE_DOWNLOAD_BYTES, default 2GB, env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via reply.hijack(), so sendRawStream must copy the status onto reply.raw by hand or a partial body ships as 200. ⚠️ Closing the preview must pause and unload media (_stopFilePreviewMedia), since a detached HTMLMediaElement keeps playing. → architecture-invariants#raw-file-bodies-streamed-and-range-aware
Ultracode / workflow-run visualization (opt-in, default OFF): the Workflow tool writes a completion artifact only at run end, so live in-flight runs exist solely as transcript dirs. workflow-run-watcher.ts therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is STANDALONE and deliberately never imports or touches subagent-watcher.ts, despite reading the same tree. Two independent toggles: showUltracodeAgents (docked panel) and ultracodeFloatingWindows (floating windows); the watcher starts if either is on. → architecture-invariants#ultracode--workflow-run-visualization
Clone a repository as a case (issue #236, Add Case → Clone Repo): POST /api/cases/clone clones synchronously into the caller's case space (bounded by GIT_CLONE_TIMEOUT_MS, no job store); POST /api/cases/clone-preflight checks anonymous cloneability and lists refs. Core in src/git-clone.ts. ⚠️ The URL is a code-execution surface: refuse every :: form and a leading -, spawn only argv arrays with -- before operands. ⚠️ Stay non-interactive (gitNonInteractiveEnv()) or the open request hangs; never collect credentials, refuse user:password@ URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing CLAUDE.md kept, hooks merged, repo .claude/settings* warned about). → architecture-invariants#clone-a-repository-as-a-case
Cross-session search: GET /api/search federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core searchSources() does substring matching with hard per-type caps: no regex (so no ReDoS) and no filesystem reads (so no traversal). The server-private externalPath is never read. PAST sessions (#261) come from session-history-index.ts, a capped snapshot of the unified list filled outside the request path (/api/sessions/unified publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through canAccessOwned() on read; history rows carry jumpTo.kind:'resume-session', since a closed session has no tab to select. → architecture-invariants#cross-session-search
Web tabs (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, NOT a SessionMode, and is proxied through Codeman's own origin (/webview/<cap>/...). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (test/webview-auth-exemption.test.ts). ⚠️ Iframes omit allow-same-origin unless trusted, and Authorization/codeman_session are stripped upstream in both modes. ⚠️ Egress guard: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (webview-egress.ts); use the undici package's own fetch + Agent, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (openLinkThroughWebTabIfLoopback), but never auto-route *.localhost (prompt-injectable DNS). Capabilities are revoked on logout (revokeOwner). → architecture-invariants#web-tabs, docs/web-tabs.md
Multi-user mode (opt-in --multiuser / CODEMAN_MULTIUSER=1, OFF by default): named users with scrypt-hashed passwords in ~/.codeman/users.json. Gated everywhere by isMultiUserMode(); when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ Not a security boundary at the agent layer: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through Session.owner and is enforced in findSessionOrFail, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → architecture-invariants#multi-user-mode, docs/multi-user-plan.md
Away digest: GET /api/away-digest aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in web/away-digest.ts. ⚠️ Returns {success:true,digest}, a legacy raw-ish shape consistent with the other raw GET handlers in system-routes.ts; frontend and tests read .digest. → architecture-invariants#away-digest
Ralph todo-config: per-session maxTodos (FIFO-eviction cap, default 500 = MAX_TODOS_PER_SESSION) + todoExpirationMinutes (auto-expiry, default 60) set via POST /api/sessions/:id/ralph-config (RalphConfigSchema, both .int().positive()). Stored on the tracker (setMaxTodos/setTodoExpirationMinutes) and persisted/read-back via RalphTrackerState (surfaced in the loopState getter → toState() + SSE broadcast → modal populateRalphForm), mirroring how maxIterations round-trips. Claude-only (skipped by isExternalCliMode).
Port interfaces: Routes declare dependencies via port interfaces (src/web/ports/). Routes use intersection types (e.g., SessionPort & EventPort).
Frontend
Frontend JS modules have @fileoverview with @dependency/@loadorder tags. Load order: constants.js(1) → i18n.js(1.5) → mobile-handlers.js(2) → voice-input.js(3) → notification-manager.js(4) → keyboard-accessory.js(5) → input-cjk.js(5.5) → terminal-keycode229-recovery.js(5.55) → sanitize-html.js(5.6) → app.js(6) → tab-rail-resize.js(6.5) → terminal-ui.js(7) → terminal-split.js(7.5) → respawn-ui.js(8) → ralph-panel.js(9) → orchestrator-panel.js(9.5) → cron-ui.js(9.7) → settings-ui.js(10) → panels-ui.js(11) → readmymind-ui.js(11.3) → ultracode-panel.js(11.5) → approvals-ui.js(11.6) → reboot-restore-ui.js(11.65) → admin-ui.js(11.7) → session-ui.js(12) → host-wake-ui.js(12.2) → webview-tabs.js(12.5) → mobile-overview.js(12.55) → home-sessions.js(12.56) → entrance-animations.js(12.6) → ralph-wizard.js(13) → api-client.js(14) → subagent-windows.js(15) → ultracode-windows.js(15.5) → session-lineage.js(15.6) → image-input.js(16). i18n.js translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; input-cjk.js handles CJK IME composition via an always-visible textarea below the terminal (window.cjkActive blocks xterm's onData). terminal-keycode229-recovery.js forwards a committed input event that xterm's _inputEvent guard drops (Chrome-on-Android soft keyboards send composed: true after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ That decision is settled at the NEXT keydown as well as on its own zero-delay timer (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the \r. On the timer alone that character is not merely late, it is LOST: xterm emits the \r first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, hell\r where the user typed hello). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by test/terminal-keycode229-recovery.browser.test.ts, which the CI gate does NOT run.
Entrance animations (entrance-animations.js, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via data-tab-anim / data-term-anim / data-win-anim / data-line-anim on <html>; the default legacy theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative animation-delay (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); blur is the ONE sanctioned filter exception, do not generalise it. ⚠️ Line glow lives in --line-glow so blur keyframes interpolate. Persisted per-device in codeman:*Anim localStorage keys, never in SettingsUpdateSchema; lab at ?animlab=1. Test: test/entrance-animations.test.ts. → architecture-invariants#entrance-animations
Mobile tab strip scrolling (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. _updateActiveTabImmediate() reveals it via computeTabScrollLeft() (constants.js, rect math on the strip's own scrollLeft, never scrollIntoView(), which scrolls the document under the fixed header); _fullRenderSessionTabs() must restore scrollLeft across rebuilds and re-reveal only when the active tab changed (_lastRenderedActiveTabId). ⚠️ The phone-block min-width on .session-tab.active .tab-name keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Test: test/mobile-tab-tap-zones.test.ts. → architecture-invariants#mobile-tab-strip-scrolling
Session list layout: header strip or left sidebar (sessionListLayout, default header; per-device via displayKeys, also in SettingsUpdateSchema): the list can move into a collapsible <aside> (Alt+B, toggleSessionSidebar) or, via tabOrientation, a resizable vertical #tabRail (desktop/tablet only). ⚠️ There is ONE #sessionTabs, MOVED between hosts, never a second list: applySessionListLayout() runs first, then applyTabOrientation(), both BEFORE applyTabWrapSettings(), and both arm/disarm _startSidebarRichClock(). ⚠️ Axis decisions use _isVerticalTabList(), never isSessionSidebarActive() alone. ⚠️ Rich rows share one gate, isRichTabRows(); rich CSS pairs sidebar+rail with comma-grouped selectors, never :is(), and card rules stay rail-scoped. ⚠️ Rail sort (tabRailSort, default activity) is the flex order property only, never a DOM reorder; the arrow-key walk alone follows computed order. ⚠️ Leaving sidebar mode clears _sidebarFilter; the handheld overlay drawer is inert when closed, the docked rail never. → architecture-invariants#session-list-layout-header-strip-vs-left-sidebar
Phone overview home screen (mobile-overview.js, per-device mobileOverviewEnabled, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in showWelcome()/hideWelcome() via width-driven shouldUseMobileOverview(). ⚠️ The container ships hidden and only this module removes it: never give .mobile-overview a bare display rule (desktop does not load mobile.css). ⚠️ The split Run button must carry the toolbar's own classes (btn-toolbar btn-run mode-<backend> / btn-run-gear) and mobile.css must set no background/color on it; row status must mirror the session-tab alert language. PAST rows resume through the shared resumeHistorySession(). Status pills carry data-i18n-skip. → architecture-invariants#phone-overview-home-screen
Desktop home tab rail (home-sessions.js, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing created … · <state> <duration> from _mobileOverviewSince(); state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: HOME_SESSIONS_MIN_WIDTH (1180) and a max-width: 1179px media query. ⚠️ .home-sessions[hidden] must re-assert display: none. ⚠️ Size all children in em off the one clamp() knob, never rem/px. Age stamps tick in place (_tickHomeSessionsTimes()), never by re-render. Test: test/home-sessions.test.ts. → architecture-invariants#desktop-home-tab-rail
Home-screen session order (CodemanSessionOrder in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank needs → error → waiting → working → idle → done. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off lastSubmitAt, never lastActivityAt (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is orderIndex, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: test/session-overview-order.test.ts. → architecture-invariants#home-screen-session-order
Welcome "Resume Conversation" list (terminal-ui.js): loadHistorySessions() fetches once and caches the corpus on _historyAll/_historyCases; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through _renderHistoryList(), so never append rows to #historyList directly or re-fetch to re-sort. ⚠️ The box height is class-driven: expanding the list without .history-list.expanded leaves the collapsed max-height in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off _historyRowLabel(), the SAME string the row renders (name || firstPrompt || path), most rows are transcript-backed and have no session name, so sorting on name alone silently does nothing. ⚠️ A filter implies expansion, and _renderSearch() hides #historyHeader (title + controls) as one unit while a search is active. Tests: test/history-list-controls.test.ts.
Command palette + shortcut registry: Ctrl/Cmd/Alt+K opens the session palette; shortcuts live in a rebindable registry (DEFAULT_SHORTCUTS/getShortcutRegistry()/matchesShortcutEvent() in app.js, overrides in settings.shortcutOverrides). ⚠️ Palette-chord keys must ALSO be swallowed in attachCustomKeyEventHandler (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ saveAppSettings() rebuilds settings from the DOM, so keys edited elsewhere (shortcutOverrides, showTokenCount, showCost) need explicit _prev carry-over. ⚠️ Smart copy (Ctrl+C): with no selection it must return true without preventDefault() or the interrupt is lost; keep copyTerminalSelection out of SHORTCUT_ACTIONS. The gate tests the CLEANED selection (CodemanCopySelection.clean, trailing padding only); never strip a shared leading indent, and leave Alt+drag column selections untouched. → architecture-invariants#command-palette-and-shortcut-registry
Per-device vs synced settings: the displayKeys set in settings-ui.js is a client-side merge policy, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; showPlanUsageLimits is additionally deleted from the incoming payload outright. Separately, SettingsUpdateSchema is .strict() and simply does not declare skin, showFileViewerButton, showCronButton, webglRendererEnabled, localEchoEnabled, cjkInputEnabled, or extendedKeyboardBar, so sending one of those is a validation error. The rest (showResponseViewer, showPlanUsageLimits, language, and most show* keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding both questions: membership in displayKeys, and presence in the schema.
Settings surface (#appSettingsModal + #sessionOptionsModal + #createCaseModal): one set-* language shared through a single :is(...) id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (switchSettingsTab scrolls); Session Options and Add Case really switch (switchOptionsTab / switchCaseModalTab), and their larger per-modal size blocks are the design, not drift. ⚠️ The load/save contract is getElementById by id: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off context (label-only rename). ⚠️ Add Case keeps its legacy .form-row markup via an adapter; every <details> there needs .set-adv-chev plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden <select>s, which stay the source of truth. ⚠️ .modal-tabs* classes are retired; admin-ui.js needs .set-rail-items + .set-doc to survive any restructure. Guard: test/app-settings-structure.test.ts. → architecture-invariants#settings-surface-app-settings-session-options-add-case
Header button visibility: most header controls are opt-in and hidden by a marker class (btn-multimonitor--hidden, btn-response-viewer-header--hidden, btn-file-viewer--hidden, btn-cron--hidden) that applyHeaderVisibilitySettings() (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by renderIndexHtml. ⚠️ Hiding must go through the marker class: the base rules are display:inline-flex !important, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; test/mobile-header-buttons-policy.test.ts is the static guard. → architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron
Gesture control (camera hand-tracking overlay, opt-in, default OFF): CODEMAN_GESTURE=1 makes the feature available; gestureControlEnabled turns it on. The bundle is injected by renderIndexHtml only when enabled, which is why that method is async and reads settings with readSettings(true) (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). Source lives in packages/gesture-control/; edit there, run npm run build:gesture, and commit the regenerated bundle because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep MP_VERSION in fetch-gesture-assets.mjs in sync with @mediapipe/tasks-vision. → architecture-invariants#gesture-control-the-source-package
Terminal font weight (terminalFontWeight / terminalFontWeightBold, per-device, default = xterm's own normal/bold): Claude Code's markdown bold is a bare ESC[1m, so the weight step is its only cue. CodemanTerminalFont.resolveWeights() (constants.js, pure) resolves each slot against its own xterm default. ⚠️ The @font-face for fonts/jetbrains-mono-variable.woff2 must stay declared 100 800 (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (refreshFont()) and open Agent Teams panes. ⚠️ Leave _awaitTerminalFont() untouched. Test: test/terminal-font-weight.test.ts. → architecture-invariants#terminal-font-weight
Theme skins / branding / i18n: skin selects a palette via data-skin on <html>, applied by an inline pre-paint script in index.html reading localStorage['codeman:skin'] to avoid a flash of wrong theme. ⚠️ A skin is four things that must stay in sync, and missing any one degrades silently: the html[data-skin="…"] token block in styles.css, the xterm ANSI palette in terminal-ui.js, the pre-paint allowlist, and the Settings picker (both in index.html). test/skin-themes.test.ts is the static guard. Light skins additionally need color-scheme: light and xterm minimumContrastRatio: 4.5, and applyTerminalSkin() must call the local-echo overlay's refreshFont() because it caches the terminal fg/bg. displayName changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. language (en/zh-CN) keeps English as the canonical source so live switching stays reversible. User display names flow through textContent/attribute APIs and the server title's HTML escaper, never innerHTML. → architecture-invariants#theme-skins
Foldable settings identity: responsive layout is width-driven via MobileDetection.getDeviceType(), but the localStorage namespace uses MobileDetection.isHandheldDevice() so an unfolded Android foldable keeps codeman-app-settings-mobile. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: OPPO Find N5 (unfolded) in test/mobile/devices.ts. → architecture-invariants#foldable-settings-identity
Folding devices: a fold is not a keyboard, and dialogs avoid the hinge: ⚠️ in handleViewportResize(), a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or keyboardVisible latches with no keyboard. ⚠️ init() must seed lastViewportWidth. ⚠️ With the keyboard up, a shape change baselines to window.innerHeight, never the shrunk visual height. ⚠️ The hinge is reserved via --fold-inline-end/--fold-block-end (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later @media block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: test/foldable-layout.test.ts. → architecture-invariants#folding-devices
WebGL renderer toggle (webglRendererEnabled, per-device): the GPU-stall watchdog's sticky codeman-webgl-disabled marker survives page loads and is cleared only by an explicit OFF→ON save or ?webgl=force. ?nowebgl forces the DOM renderer per-load. → architecture-invariants#webgl-renderer-toggle
Shell keyboard accessory bar + one-shot Ctrl (keyboard-accessory.js): a shell-mode session swaps the mobile accessory bar for terminal controls; setMode() records extendedKeyboardBar as the base layout and refreshForActiveSession() resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in terminal.onData (after shouldSuppressTerminalQueryResponse, before every send path), and must skip isTerminalFocusOrMouseReport() chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ _handleCjkInput() must apply it too (the CJK textarea bypasses onData). Mapping: ctrlByteFor(). ⚠️ mobile.css's light-skin repaint must keep excluding .accessory-btn:not(.armed) or the armed state is invisible. → architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl
Mobile prompt composer (keyboard-accessory.js): the agent bars' Paste key is Compose, a native multiline dialog where only Send submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (_takePendingLocalEcho), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (_composerDrafts), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via _sendInputAsync WITHOUT useMux, then a separate delayed Enter WITH it; never terminal.paste(), and the frame must never take the mux fallback (it strips newlines). ⚠️ _composerMaxLength must stay derived from MAX_INPUT_LENGTH minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: test/mobile-prompt-composer.test.ts. → architecture-invariants#mobile-prompt-composer
Dismissing the on-screen keyboard (terminal-ui.js): two gestures blur the terminal's hidden textarea. (1) _installMobileKeyboardDismiss(), a document touchend that must never fire inside #terminalContainer or on a control (MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, via closest()). (2) In _handleMobileTerminalTap, a second tap on inert content blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in touchend: both classifiers must share one threshold (TAP_THRESHOLD reads MOBILE_KEYBOARD_DISMISS_TAP_SLOP), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run npm run test:mobile -- test/mobile/keyboard.test.ts by hand and diff the FAIL list against master. → architecture-invariants#dismissing-the-on-screen-keyboard
Phone toolbar: Enter replaces Shell (post-1.8.0): inside @media (max-width: 599px) btn-shell is display:none and btn-enter takes its slot (order: 4); starting a shell moved into the Run dropdown (Terminal / Shell → setRunMode('shell') → run() → runShell(), button label "Run SH"). runMode is z.string().max(20) server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
⚠️ sendEnterKey() MUST go through terminal._core.coreService.triggerDataEvent('\r', true) — not sendInput(), and never a raw POST to /api/sessions/:id/input. localEchoEnabled defaults to MobileDetection.isTouchDevice(), so on every phone the characters you type are buffered in the LocalEchoOverlay and have never reached the PTY; the onData Enter branch in terminal-ui.js is what flushes pendingText first and only then sends \r (after an 80ms delay so text lands first). Sending a bare \r submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. KeyboardAccessory.sendKey() is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
⚠️ Skin overrides outrank plain class rules. styles.css nests its skin block inside html:not([data-skin="og"]) { … }, so a bare .btn-toolbar rule in there resolves to specificity (0,2,1) and beats a .btn-toolbar.btn-x rule (0,2,0) in mobile.css regardless of load order. Toolbar-button colors set from mobile.css therefore need !important — that is why mobile.css leans on it so heavily. Symptom: only your !important properties land and everything else silently renders in generic toolbar grey.
Connection-loss UI (computeConnectionLossUi() in constants.js, writer _updateConnectionLossUi() in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen overlay while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking banner once it has (the terminal scrollback stays readable). ⚠️ A 2.5s grace is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. navigator.onLine === false skips the grace, since that is never a blip. Retry re-arms SSE and the terminal WS (planWsReconnect can 'give-up', and the SSE backoff caps at 30s).
SSE staleness watchdog (computeSseStale() in constants.js, _checkSseStale() + a 5s interval in app.js): an EventSource can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named sse:heartbeat event (cleanupDeadClients(), sse-stream-manager.ts), never an SSE comment, which EventSource cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while connected and online (the loop breaker). ⚠️ The liveness stamp lives inside addListener. ⚠️ Clear the interval only at the top of connectSSE(), or intervals stack. → architecture-invariants#sse-staleness-watchdog
Z-index layers (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its [hidden] must re-assert display: none or dismiss() leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → architecture-invariants#z-index-layers
Respawn presets: solo-work (3s/60min), subagent-workflow (45s/240min), team-lead (90s/480min), ralph-todo (8s/480min), overnight-autonomous (10s/480min).
Keyboard shortcuts: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; _installShiftDragSelection), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
Security
Full model: docs/security-architecture.md (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). Layer-by-layer detail with the history behind each: architecture-invariants#security-layers.
| Layer | The rule |
|---|---|
| Auth | Optional HTTP Basic via CODEMAN_USERNAME (default admin) / CODEMAN_PASSWORD. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts) |
| Network bind | Defaults to loopback. Non-loopback without a password starts but warns loudly. Classifier: network-auth-policy.ts |
| Host guard | Always-on Host-header allowlist blocking DNS rebinding. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix |
| CSRF / Origin | Always-on cross-site Origin guard on state-changing requests. A missing Origin is allowed so curl/CLI and hooks keep working. ⚠️ The body parser keeps text/plain RAW; auto-JSON-parsing it enabled simple-request CSRF |
| QR Auth | Single-use 6-char tokens (60s TTL) for tunnel login. See docs/qr-auth-plan.md |
| Sessions | 24h cookie (codeman_session), auto-extend, device context audit |
| Rate limit | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
| Hook bypass | /api/hook-event + /api/status-telemetry skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires X-Codeman-Hook-Secret unconditionally (Codeman cannot detect a user's own loopback reverse proxy) |
| Lost-frame page | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a GET/HEAD carrying Sec-Fetch-Dest: iframe|frame, Accept: text/html and mode navigate (or none), for a path that is NOT a registered route (never /api/, /ws/, /q/), is answered BEFORE the credential checks with the static web-tab recovery page (lostWebviewFramePage: no reflected input, default-src 'none' plus its own script hash, no-store). / is the one registered route also admitted, only when the request carries neither codeman_session nor Authorization (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly / and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in docs/api-reference.md. Pinned by test/webview-auth-exemption.test.ts + test/webview-lost-root-frame.test.ts |
| Tunnel | Enabling a tunnel refuses without CODEMAN_PASSWORD unless exposure is acknowledged via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 or the per-request acknowledgeUnauthTunnel:true action field (never persisted) |
| Validation | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*/PI_*/GROK_*/XAI_*/DSH_*/DEEPSEEK_*) |
| Headers | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
Security-relevant env vars: CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway).
SSE Event Registry
Event constants live in src/web/sse-events.ts (backend) and SSE_EVENTS in constants.js (frontend). Both must be kept in sync; test/sse-registry-parity.test.ts pins it. ⚠️ hook:agent_working is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's @fileoverview carries the per-category breakdown, including the two Web tab events.
API Routes
One module per domain in src/web/routes/ (plus a barrel; ls src/web/routes/ for the current list). Beyond the /api routes: the /webview/:cap/* proxy, the /ws/voice/stream relay and the terminal WebSocket. Each file has @fileoverview with endpoint details.
HTTP contract (stable since 0.9.x, see docs/versioning-policy.md; full envelope/status/error-code/SSE spec in docs/api-reference.md): responses use the ApiResponse<T> envelope — { success: true, data? } or { success: false, error, errorCode } (src/types/api.ts). /api/v1/* is a versioned alias of /api/* (URL rewrite in server.ts).
Adding Features
- API endpoint: Types in
src/types/domain file, route 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: Pick unique port (search
const PORT =). 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().
State Files
All in ~/.codeman/: state.json (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), mux-sessions.json (tmux recovery), settings.json (user prefs), push-keys.json + push-subscriptions.json, session-lifecycle.jsonl (audit log), update-status.json (self-updater progress, polled across the service restart), docker-env-applied.json (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by Start-Codeman.sh, read by the self-updater's environment gate), docker-build-source.json (Compose deployment only: the checkout's HEAD commit and package-lock.json hash the codeman-node-modules/codeman-dist volumes currently reflect, written by both Start-Codeman.sh and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), linked-cases.json, webviews.json (saved web-tab dashboard URLs), remote-hosts.json + remote-cases.json, docker-hosts.json + docker-cases.json + docker-exports/, subagent-window-states.json + subagent-parents.json (subagent window layout, GET/PUT /api/subagent-window-states/-parents), hook-secret (per-instance), users.json (multi-user, mode 0600) + admin-audit.jsonl, intents.json (Read My Mind intent profiles, mode 0600), 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.
Testing
npm test is the gate and is safe to run bare — it runs config/vitest.ci.config.ts, exactly what CI runs, so local green means CI green.
npm test # The gate — what CI runs
npm test -- test/<specific-file>.test.ts # Single file
npm test -- -t "pattern" # By name
Three suites are deliberately left out, because they cannot pass on an arbitrary machine. Each has its own runner, and a failure there means "not runnable here", not a regression:
npm run test:browser # Playwright + chromium, live server; codex-predictive-echo also needs a real codex binary
npm run test:mobile # the above plus environment-specific PNG baselines (own config, own pretest vendor step)
npm run test:perf # wall-clock benchmarks — need an otherwise idle machine
npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default
⚠️ npm test cannot see those suites, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above).
⚠️ A file filter must match the runner. npm test -- test/mobile/keyboard.test.ts matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (npm run test:mobile -- <file>, npm run test:browser -- <file>, npm run test:perf -- <file>). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour.
Raw npx vitest skips the config (and with it setup.ts); always use npm test -- or pass --config.
Config: Vitest with globals: true, fileParallelism: false. Timeout 30s, teardown 60s. config/vitest.config.ts is the everything-config behind test:all; config/vitest.ci.config.ts is the gate and derives its excludes from config/test-suites.ts, which is also what vitest.browser.config.ts and vitest.perf.config.ts derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them.
Tmux safety: under vitest (VITEST), TmuxManager no-ops ALL shell commands (IS_TEST_MODE in src/tmux-manager.ts), docker IO is no-op'd likewise, and Session spawns an echo PTY (TEST_PTY_SCRIPT) instead of attaching tmux. test/setup.ts gives each file a temp HOME/USERPROFILE and strips CODEMAN_PASSWORD/CODEMAN_USERNAME, CODEMAN_GESTURE and CODEMAN_INSTANCE/CODEMAN_DATA_DIR/CODEMAN_TMUX_SOCKET (pinned by test/test-env-isolation.test.ts). ⚠️ CODEMAN_DATA_DIR overrides the temp HOME, so never drop its strip; strip CODEMAN_INSTANCE in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via safeRmHomeTree(). ⚠️ Raw npx vitest without --config skips setup.ts and its isolation. → architecture-invariants#test-isolation-tmux-docker-and-home
Ports: Pick unique ports manually, 3150+. Search const PORT = before adding new tests. Never 3000 (the live instance).
⚠️ Browser tests can pass vacuously on mobile input paths. Two traps, both hit on 2026-07-27 while fixing the phone Enter button: (1) driving input with app.sendInput('…') writes PAST the LocalEchoOverlay, so pendingText stays empty and any overlay bug is invisible — type with page.keyboard.type() instead; (2) headless Chromium reports MobileDetection.isTouchDevice() false even with hasTouch: true, so _localEchoEnabled is off and the local-echo branch never executes. Force it (app._localEchoEnabled = true) or the test proves nothing. Assert on real state (app._localEchoOverlay.pendingText, plus tmux -L codeman capture-pane -p -t <pane> for what actually reached the PTY), not on HTTP 200.
Testing against the live instance: prod is HTTPS-only on :3000 (curl -sk https://localhost:3000/...). ⚠️ w1/w2/w3 are the user's REAL sessions — never send input to them. Create your own throwaway session (POST /api/sessions then POST /api/sessions/:id/shell; creation alone leaves pid: null and no pane), test against that, and DELETE it by exact id when done.
Respawn tests: Use MockSession from test/mocks/index.ts (defined in test/mocks/mock-session.ts). Route tests: app.inject({ method, url, payload }) in test/routes/ — no live port needed. Mobile tests: Playwright suite in test/mobile/ (device profiles in test/mobile/devices.ts). Browser-testing infra and practices: docs/browser-testing-guide.md.
Debugging
tmux -L codeman list-sessions # Codeman's own socket (bare `tmux` shows the default one)
curl -sk https://localhost:3000/api/sessions | jq # Check sessions (prod is HTTPS-only; dev on :3000 is plain http)
curl -sk https://localhost:3000/api/status | jq # Full app state
curl -sk https://localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq # Persisted state
Mobile screenshots: ~/.codeman/screenshots/, accessed via GET/POST /api/screenshots.
Performance & Limits
Target: 20 sessions, 50 agent windows at 60fps. Limits live in src/config/ (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable.
Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is clamped to ≤75% of max, because a trim ≥ max would disable BufferAccumulator trimming entirely and make memory unbounded; and browser xterm scrollback is a separate hardcoded 50k (DEFAULT_SCROLLBACK in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. tmux <3.7 allocates history-limit at pane creation, while tmux 3.7+ can resize live panes (lowering the value can discard retained lines); already-evicted lines never return. The settings keys terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes are schema-validated but inert; only tmuxHistoryLimit is wired. → architecture-invariants#buffers-uploads-and-terminal-history, docs/terminal-anti-flicker.md
Memory leaks (24+ hour sessions): use CleanupManager, clear Maps in stop(), guard async with if (this.cleanup.isStopped) return. Frontend: store handler refs, clean in close*(). Use LRUMap for bounded caches, StaleExpirationMap for TTL cleanup. Verify: npm test -- test/memory-leak-prevention.test.ts.
Scripts & Tunnel
install.sh (repo root) is the public curl | bash installer: it installs Node/tmux/git/build tools, clones to ~/.codeman/app, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in ~/.codeman/install.log). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (read_existing_binding()). ⚠️ Compose the hand-start env only in start_command_hint/export_bind_env, and call stop_background_helpers before exec. ⚠️ Tailscale rename is opt-in (default NO, never under --yes/non-interactive); NEVER tailscale serve reset, touch a mapping it did not create, run tailscale funnel or advertise a Service (test/install-sh-invariants.test.ts). ⚠️ Stay bash 3.2 clean (no declare -A, mapfile, namerefs, ${x,,}, here-strings, empty-array expansion under set -u). ⚠️ Execute only commands from the generated CLI block (CLI_INSTALL_CMD_TRUSTED, npm run generate:cli-catalog). → architecture-invariants#installsh-the-public-installer
Other key scripts: scripts/tmux-manager.sh (safe tmux mgmt), scripts/tunnel.sh [quick|named] start|stop|status|url (quick = random trycloudflare URL, default; named setup|enable = fixed-hostname tunnel via scripts/codeman-tunnel-named.service; bare start|stop|url still means quick), scripts/run-beta.sh (isolated beta instance), scripts/build-agent-image.mjs (docker base image), scripts/self-update.sh (detached updater). Production services: scripts/codeman-web.service, scripts/codeman-tunnel.service. Always set CODEMAN_PASSWORD before exposing via tunnel.