136 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).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) andinstall.sh(its raw URL is the published install one-liner). Don't relocate those.
Quick Reference
| Task | Command |
|---|---|
| Dev server | npm run dev (or npx tsx src/index.ts web) |
| Type check | 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).
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.19.7 (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) and Pi (pi.dev) CLIs via pluggable CLI resolvers (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi').
TypeScript Strictness (see tsconfig.json): noUnusedLocals, noUnusedParameters, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch, allowUnreachableCode: false, allowUnusedLabels: false.
Requirements: Node.js 22+, Claude CLI, tmux
Git: Main branch is master. SSH session chooser: sc (interactive), sc 2 (quick attach), sc -l (list).
Additional Commands
npm run dev = dev server. Default port: 3000 (override with --port or the CODEMAN_PORT env var). To run this beta isolated alongside a prod Codeman, use scripts/run-beta.sh (sets CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000). Commands not in Quick Reference:
| Task | Command |
|---|---|
| Dev with TLS | npx tsx src/index.ts web --https |
| Override window title hostname | npx tsx src/index.ts web --title-hostname <name> (default: os.hostname() — codeman:<name> is used for tab title, title-flash, and OS desktop notification prefix) |
| Bind a non-loopback host | npx tsx src/index.ts web --host 0.0.0.0 (or -H; env CODEMAN_HOST; default 127.0.0.1). Without CODEMAN_PASSWORD it starts but warns loudly — see Common Gotchas + docs/security-architecture.md |
| Continuous typecheck | tsc --noEmit --watch |
| Watch-mode test | npm run test:watch -- test/<file>.test.ts (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) |
| 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) |
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 5 Playwright tests; globs live in config/test-suites.ts). 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).
Code style: Prettier (singleQuote: true, printWidth: 120, trailingComma: "es5") — config lives in the "prettier" key of package.json, not a .prettierrc (keeps the repo root short; editors read it natively). .prettierignore stays at the root because Prettier resolves it relative to cwd. ESLint flat config (config/eslint.config.js) allows no-console, warns on @typescript-eslint/no-explicit-any. Ignores: app.js, scripts/**/*.mjs, src/web/public/vendor/**, scripts/remotion/**.
Prettier scope is deliberately narrow. npm run format globs only src/**/*.ts and src/web/public/**, and .prettierignore then exempts most of src/web/public/*.js (app.js, styles.css, 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 - 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_*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). → 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_*) 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). 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. Resolver design pattern:docs/opencode-integration.md,docs/pi-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', …). → 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. ⚠️ 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 |
The last three back web -d / service install |
| Session | src/session.ts ★, session-manager, session-auto-ops, session-cli-builder, session-task-cache, session-order (pure), session-pty-exit-breaker, usage-limit-patterns, usage-telemetry; src/services/unified-session-service.ts |
Pure/unit-tested helpers are split out of session.ts on purpose |
| Mux | src/mux-interface.ts, src/mux-factory.ts, src/tmux-manager.ts ★ |
|
| Respawn | src/respawn-controller.ts ★ + 4 helpers (-adaptive-timing, -health, -metrics, -patterns) |
Read docs/respawn-state-machine.md first |
| Ralph | src/ralph-tracker.ts ★, src/ralph-loop.ts + 5 helpers (-config, -fix-plan-watcher, -plan-tracker, -stall-detector, -status-parser) |
Read docs/ralph-wiggum-guide.md first |
| Orchestrator | src/orchestrator-loop.ts, -planner, -verifier |
Read docs/orchestrator-loop-architecture.md first |
| Cron | src/cron/cron-service.ts, cron-time.ts (pure next-run math), cron-input.ts |
Read docs/cron-discovery.md first. Distinct from legacy ScheduledRun (/api/scheduled) |
| Agents | src/subagent-watcher.ts ★, team-watcher, bash-tool-parser, transcript-watcher, workflow-run-watcher |
workflow-run-watcher is STANDALONE and never touches subagent-watcher |
| AI | src/ai-checker-base.ts, ai-idle-checker.ts, ai-plan-checker.ts |
|
| Tasks | src/task.ts, task-queue.ts, task-tracker.ts |
|
| State | src/state-store.ts, run-summary.ts, session-lifecycle-log.ts, intent-store.ts |
|
| Infra | src/hooks-config.ts, push-store, tunnel-manager, image-watcher, file-stream-manager, remote-hosts + remote-reconnect (pure), docker-hosts + docker-export |
Remote/docker case overlays; see Key Patterns |
| Web tabs | src/webview-store.ts, webview-capabilities.ts, src/web/webview-proxy.ts (pure), src/web/routes/webview-routes.ts |
Dashboard URLs as tabs; NOT a SessionMode |
| Search | src/search-service.ts |
Pure in-memory core for GET /api/search |
| Attachments | src/attachment-registry.ts, attachment-magic, generated-artifact-attachments, session-attachment-history, document-preview-cache, document-thumbnailer, document-conversion-limiter, config/attachment-guard |
See Key Patterns |
| Plan | src/plan-orchestrator.ts, src/prompts/*.ts, src/templates/ (claude-md.ts + case-template.md) |
templates/ holds the CLAUDE.md scaffold generated into new cases |
| Web | src/web/server.ts ★, sse-events.ts, routes/*.ts (24 modules + barrel; session-routes.ts ★), route-helpers.ts, ports/*.ts, middleware/auth.ts, schemas.ts, self-update.ts, plan-usage-latest.ts, ws-connection-registry.ts, heic-jpeg-converter.ts + heic-jpeg-worker.ts |
|
| Frontend | src/web/public/app.js (~5K lines, core) + 30 modules + sw.js |
See Frontend section for the load order, which is authoritative |
| Types | src/types/index.ts (barrel) → 22 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/ — 21 files, no barrel (index.ts) exists; import from the specific file.
Utilities: src/utils/ — re-exported via index. Key: CleanupManager, LRUMap (⚠ NOT in the barrel — import from ./utils/lru-map.js directly), StaleExpirationMap, BufferAccumulator, stripAnsi, Debouncer, KeyedDebouncer. Also: claude-cli-resolver/opencode-cli-resolver/codex-cli-resolver/gemini-cli-resolver/antigravity-cli-resolver/pi-cli-resolver (CLI path resolution; ⚠ pi-cli-resolver additionally version-probes the binary, since pi is a generic name), string-similarity (fuzzy matching), regex-patterns (ANSI/token/spinner patterns), assertNever (exhaustive checks), token-validation (auth tokens), nice-wrapper (process priority).
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 so an agent driving Codeman from a shell can block instead of poll: GET /api/sessions/:id/wait (lifecycle signal), GET /api/sessions/:id/wait-output (literal substring, never regex) and wait/waitTimeout on POST /api/sessions/:id/input. Registry in session-wait-registry.ts (pure, no Session reference), bounds in config/agent-wait.ts. ⚠️ A timeout is a 200 (wait.timedOut), never an error, so callers loop over short waits. ⚠️ stop/blocked come from Claude Code hooks and therefore fire for claude mode ONLY (shell installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must notifySignal('exit') BEFORE cancelAll(). ⚠️ Client-hangup abort listens on reply.raw guarded by writableFinished: on req.raw, close fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no app.inject() test could see it. ⚠️ Worker liveness cannot come from session.pid — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (isPaneDead, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched wait-output markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the skills/codeman agent skill: installable via codeman skill install [--case <name>] / skill uninstall, or auto-injected into a case's .claude/skills/ on Claude session create behind agentSkillEnabled (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (applyAgentSkill in hooks-config.ts never touches an unmarked user copy) and refuses symlinks (this repo's own .claude/skills/codeman is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (~/.claude/skills/codeman, written once by codeman skill install with no --case) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (refreshUserAgentSkill; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (seedAgentSessionPreamble → ${XDG_CACHE_HOME:-~/.cache}/codeman-agent-<id>.sh, local claude sessions only), single-sourced from skills/codeman/preamble.sh and pinned byte-identical to SKILL.md's §0 heredoc by test/agent-skill.test.ts, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → architecture-invariants#agent-wait-primitives, docs/api-reference.md
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 (❯) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting status:"idle"). Its working indicator is ✻ Actualizing… (13m 23s · ↓ 47.5k tokens): the glyph animates through · ✢ ✳ ∗ ✻ ✽, the gerund is randomized, and the finished line (✻ Cooked for 2m 49s) carries the same glyph, so neither SPINNER_PATTERN (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: _confirmIdle() (session.ts) requires the pane to go quiet, and then asks the SCREEN via capturePaneText() + CLAUDE_WORKING_LINE_PATTERN before believing it; a sustained run of repaints (session-activity.ts, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no ❯, so nothing would ever arm the confirmation and the session would latch busy.
Auto-resume on usage limit (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, usage-limit-patterns.ts (pure, unit-tested) parses the reset time and SessionAutoOps arms a timer for reset+2min, then sends Esc + continue. ⚠️ Respawn cycles are blocked while paused (isLimitPaused guard in onIdleDetected), which is what prevents /clear from wiping the paused conversation. Claude-mode only. → architecture-invariants#auto-resume-on-usage-limit
Plan-usage chip (statusLine telemetry, showPlanUsageLimits, per-device: desktop default ON, handhelds OFF via the mobile block in getDefaultSettings()): resolve it ONLY through planUsageChipEnabled() in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the statusLineTelemetry flag on session create). A chip shown without telemetry renders — forever. Codeman injects its own statusLine.command exporter which POSTs Claude's rate_limits blob to POST /api/status-telemetry. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is ours, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit message rather than showing live %. → architecture-invariants#plan-usage-chip-statusline-telemetry, docs/usage-limits-display-plan.md
Orchestrator: State machine that turns a user goal into a phased plan and drives it to completion: idle → planning → approval → executing → verifying → (replanning) → completed/failed. OrchestratorLoop (engine) delegates plan generation to orchestrator-planner and per-phase verification gates to orchestrator-verifier, executing phases via team agents/task-queue. State persists under the orchestrator key in state.json. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See docs/orchestrator-loop-architecture.md.
Cron (CronJobs): saved, named jobs on a recurring schedule (once/interval/daily/weekly) with per-job run history. ⚠️ Distinct from the legacy ScheduledRun (/api/scheduled, a run-now duration-bounded loop); the two never interact and keep separate Scheduled* / Cron* names. CronService reuses the existing session layer rather than rebuilding tmux logic. Next-run math is pure and unit-tested in cron-time.ts (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → architecture-invariants#cron-jobs, docs/cron-discovery.md
Remote sessions + remote SSH cases: a case can point at a remote host. The agent runs inside a durable remote tmux -L codeman-remote (session name codeman-ssh-<id>, deliberately failing the remote Codeman's SAFE_MUX_NAME_PATTERN so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running ssh. Attached (owned:false) sessions detach, never kill on tab close; owned ones propagate kill-session. A bounded-backoff watcher auto-reconnects dropped sessions (remoteAutoReconnect, default ON). ⚠️ Command-injection surface: every ssh command line must flow through buildSshConnectionArgs(), which shellescapes every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through POST /api/quick-start, not POST /api/sessions (which stat-validates workingDir locally and has no caseName). → architecture-invariants#remote-sessions-over-ssh, #remote-ssh-cases, docs/remote-sessions.md
Docker cases: a case can point at a container, with any of the five CLI backends running inside it. Like remote-SSH this is a LOCATION OVERLAY on cases, never a sixth SessionMode. Exactly one long-lived container per case, shared by all its sessions, so killing a session kills only that session's in-container tmux and never docker stop while siblings remain. The workspace is a real host dir bind-mounted at the same absolute path, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are seeded (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from docker commit so exports stay secret-free. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need CODEMAN_DOCKER_BRIDGE_HOOKS=1; otherwise idle detection falls back to output-based. → architecture-invariants#docker-cases, docs/docker-cases.md (user guide), docs/docker-cases-plan.md (design)
External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi): isExternalCliMode() in session.ts gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All five require tmux with no direct PTY fallback, because secrets are injected via socket-scoped tmux setenv and never on the spawn command line. ⚠️ run*() in session-ui.js MUST unwrap the {success,data} envelope; reading the raw shape silently breaks the run. ⚠️ Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay (_localEchoPolicy in _updateLocalEchoState, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (_localEchoEnabled remains false for codex). Instead, PredictiveEchoAddon (separate vendor/xterm-predictive-echo.js bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (_predictHookOnData) is a plain statement with no return, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (isCodexComposerRow, /^› /). Codex also drops keystrokes that share a PTY read with a bracketed paste, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed \r). Tests: test/local-echo-codex-gating.test.ts, test/codex-predictive-echo.test.ts (E2E vs real codex), packages/xterm-zerolag-input/test/codex-replay.test.ts. ⚠️ Pi is the opposite kind of CLI and needs the opposite instincts: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state approveProjectTrust (--approve/--no-approve), which makes pi EXECUTE repo-local .pi/extensions TypeScript, so the multi-user clamp puts pi in the materialize branch (an absent config still yields --no-approve for a non-granted owner) and --api-key is never wired. Pi stays OUT of isAltScreenStripMode() (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via /settings, where the alt screen is load-bearing), and lands on the 'buffer' echo policy via the _updateLocalEchoState fallthrough. Pi's own tests: test/pi-mode.test.ts, test/routes/external-cli-bypass-clamp.test.ts; user guide docs/pi-integration.md. → architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi
Run launch synchronization: the Run entrypoint holds an in-flight lock and disables #runBtn for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same w<n>-<case> name. _ensureCreatedSessionVisible() runs before selectSession(), and _onSessionCreated() stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ Closing has the mirror-image race and one owner: closeSession() reads wasActive BEFORE its await and announces the delete via _closingSessions, while _onSessionDeleted skips the active-session handoff for an id in that set. Both used to read activeSessionId after the fact, so the session_deleted broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in sessions (a dead id can linger in sessionOrder, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. 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 the session that spawned it, as a parentSessionId body field on POST /api/sessions / POST /api/quick-start or the X-Codeman-Parent-Session header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). resolveParentSessionId() (route-helpers.ts) resolves rather than trusts it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and anything unresolvable is DROPPED, never a 400 — a cosmetic field must not be able to fail a worker spawn. It rides toState() into session_created, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (_appendLineageConnectionLines called at the tail of _updateConnectionLinesImmediate(), exactly like ultracode), sharing one batched read→write reflow and the tab:<id> rect cache; geometry is pure in computeLineagePath() (constants.js). ⚠️ ONE shape, and the second one was the bug: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a mis-tuned-in-both-directions corridor (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the STRIP's bottom edge (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ Colors are keyed on the SPAWNING tab, not per child: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles CodemanLineage.COLORS in first-seen order per parent id (first entry empty = the skin-tuned --session-blue, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as --lineage-color so styles.css keeps owning opacity/glow/dash. test/session-lineage-lines.test.ts drives the real _appendLineageConnectionLines() and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ Desktop only: the overlay is z-index: 999 and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header fixed; z-index: 1200 and would bury them. ⚠️ Paths carry data-agent-id="lineage:<childId>" because that is what _applyLineEntrances() queries — that one attribute is what gives them the entrance animation and its negative-animation-delay resume across svg.innerHTML=''. ⚠️ .session-tabs is overflow-x: auto, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive scroll listener re-anchors the rest.
Unified session list: GET /api/sessions/unified merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in src/services/unified-session-service.ts). Transcript rows fold into their owning session via a claudeSessionId → Codeman id alias map, so resumed and /clear-respawned sessions do not appear twice. No terminal buffers in the response, unlike /api/sessions. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (PUT /api/session-order; pure merge helpers in src/session-order.ts, pushing device wins and server-only ids are never dropped). → architecture-invariants#unified-session-list-and-session-manager
Hook events: Claude Code hooks trigger via /api/hook-event. Key events: permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, idle_prompt, stop, teammate_idle, task_completed. See src/hooks-config.ts; upstream hook semantics mirrored in docs/claude-code-hooks-reference.md. ⚠️ Every claude session INSTALLS the hooks block into its workspace (applyWorkspaceHooks in hooks-config.ts → ensureCodemanHooks, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from restoreMuxSessions() for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm idle, with no Approvals Inbox item, no push, no definitive stop/idle_prompt for respawn and no stop/blocked for the wait endpoints. The escape hatch is the synced workspaceHooksEnabled setting (App Settings → Agents & CLIs → Claude, default ON); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through applyWorkspaceHooks rather than calling ensureCodemanHooks at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS settings.local.json, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as permission_prompt, not elicitation_dialog (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
Approvals Inbox (cross-session queue of prompts waiting on a human; approvalsInboxEnabled, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): web/approval-inbox.ts is a sessionWaits-style singleton fed by /api/hook-event, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via POST /api/approvals/:id/answer, which sends a digit / Esc / idle-prompt text through writeViaMux (menu answers never carry \r). ⚠️ option digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic working signal is restricted to idle items; permission/question items clear only on definitive signals (stop, elicitation_complete/elicitation_response, exit/delete, answer, supersede, 12h TTL). ⚠️ Viewing a session ACKNOWLEDGES its idle item, it does not resolve it (POST /api/approvals/session/:sessionId/viewed → acknowledgedAt → approval:updated): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and seedApprovals() re-armed the alert on the next reload while other devices never heard about it at all; the local half is markIdleAlertSeen() (app.js), called from BOTH selectSession paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ Only a HUMAN opening a session acknowledges: selectSession(id, { auto: true }) marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; test/session-select-ack-gate.test.ts pins both the gate and the tagged call sites. Idle-only by construction (acknowledge() defaults to ['idle']): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: _ackDelivery (app.js) spends the IDLE alert only, via that same markIdleAlertSeen(). It used to clearPendingHooks(sessionId) with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only elicitation_complete/elicitation_response, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until stop: GET /api/approvals therefore runs a staleness sweep over the caller's own items via verifyStillAnswerable(), which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). The frontend seeds from GET /api/approvals in handleInit regardless of the setting: the seed re-arms the tab-alert state machine (setPendingHook) unconditionally, and only populating this.approvals (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); _onApprovalResolved clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (sendPushNotifications strips actions/approvalId when OFF) and are answered from sw.js directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (approvals-ui.js), phone overview NEEDS YOU answer strips (mobile-overview.js). Design: docs/approvals-inbox-plan.md.
Read My Mind intent profiles (phase 1 of docs/readmymind-plan.md; readMyMindEnabled, SYNCED, default OFF): per-CASE profiles (user-stated goals + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive /clear/respawns and multi-user scoping is structural. Capture rides the transcript (transcript:user_prompt from transcript-watcher.ts), NOT the input paths: POST /input sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside startTranscriptWatcher()'s if (!watcher) block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: src/intent-store.ts singleton, intents.json written 0600 tmp+rename (prompts can contain secrets; never fed to /api/search). Endpoints: GET/PUT/DELETE /api/sessions/:id/intent + POST /api/sessions/:id/readmymind (readmymind-routes.ts, ownership via findSessionOrFail WITH req; registrations stay the bare app.<method>('path') shape, the endpoints.md drift scanner cannot see generics). Phase 2 (predictor + 🧠 button): readmymind-context.ts is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in readmymind-collectors.ts (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; readmymind-predictor.ts reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = readMyMindModel setting defaulting to AI_CHECK_MODEL (opus, decided). Frontend readmymind-ui.js: header 🧠 marker-hidden (btn-readmymind--hidden) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the rmm-enabled class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every applyHeaderVisibilitySettings()). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (#readMyMindSteer, sent as steer, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/textContent ONLY and Send/Insert go through POST /input (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: docs/readmymind.md.
Voice dictation via Claude (claudeVoiceEnabled, SYNCED, default OFF): the mic button can transcribe through this machine's Claude Code login instead of a Deepgram key, using the same speech-to-text service the CLI's own /voice mode uses. ⚠️ Claude Code's voice mode itself is unusable here: it opens the HOST's microphone (sox/arecord), and the CLI runs in a headless tmux pane while the human is in a browser elsewhere. So Codeman captures in the browser and borrows only the backend. Audio goes browser → Codeman → Anthropic (src/web/voice-stream.ts): the OAuth token never reaches the page, and the browser only sends PCM and receives text. ⚠️ Credentials are read-only (src/claude-credentials.ts) and Codeman never refreshes them — a refresh rotates the refresh token and could sign the user out of their own CLI; an elapsed token reports expired instead. ⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an AudioWorklet, not MediaRecorder (which cannot emit raw PCM); voice-pcm-worklet.js is fetched from JS, so it is invisible to cacheBustAssets and borrows voice-input.js's ?v= token — edit the two together. ⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is voiceSettings.provider (auto prefers Claude → Deepgram → Web Speech). → docs/claude-voice-plan.md
Agent Teams: TeamWatcher polls ~/.claude/teams/, matches to sessions via leadSessionId. Teammates are in-process threads appearing as subagents. Enable: CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1. See docs/agent-teams/.
Circuit breakers: the Ralph breaker prevents respawn thrashing (CLOSED → HALF_OPEN → OPEN; reset via /api/sessions/:id/ralph-circuit-breaker/reset). Distinct: the PTY-exit breaker (session-pty-exit-breaker.ts) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit {clearBreaker:true} body on POST /api/sessions/:id/interactive; the frontend's auto-reattach in selectSession() sends no body and must never clear it. → architecture-invariants#circuit-breakers-ralph--pty-exit
Full-scrollback replay: GET /api/sessions/:id/terminal?full=1 returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (source='mux-full-history'), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests full=1 (_fullHistoryLoaded Set); Shell selection always starts from a bounded 1 MiB ?tail= window and loads the rest only when Load full history is pressed. Ordinary Shell scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — _replayWouldShrinkBuffer() refuses it and slows that session's cooldown to 60s. → architecture-invariants#full-scrollback-replay
Terminal touch gestures: link taps and text selection: on a touch device xterm's own handlers see neither — touch-action: none plus touchstart's preventDefault suppress the browser's compatibility mouse events, _installMobileTapMouseGuard drops the trusted ones that still arrive, and the synthetic mousedown/mouseup pair dispatched for mouse REPORTING goes to the .xterm root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ A tap activates the link under it through the SAME provider that feeds the hover linkifier (_terminalLinkAtPoint, containment mirroring xterm's _linkAtPosition), synchronously inside touchend — that is what keeps the user gesture window.open needs — and BEFORE any mouse report, mirroring _handleDesktopTerminalClick's skip for a hovered link. Two rows keep their meaning: the caret's logical line (_tapIsOnCaretLine, where a tap places the cursor in text the USER typed) and TUI-owned rows (_isActionableMobileTerminalTap, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as 'input' and gating on that would leave every URL in shell output inert. ⚠️ Long-press selects by driving xterm's public select() (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through copyTerminalSelection() for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after touchend (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and contextmenu is suppressed for the gesture window), and copyTerminalSelection()'s closing terminal.focus() (right on desktop, wrong on a phone). Tests: test/terminal-touch-tap.test.ts.
Terminal scrollback strip + wheel/touch forwarding (#205): codex/claude/gemini get the FULL strip (alt-screen, 3J, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time smcup, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on useMux: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for claude ≥ 2.1.187 ONLY at ANY scroll position (snap-to-bottom first); Shift+wheel and the terminalWheelLocalScrollback setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (mouse_any_flag=0, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). _wheelScrollLines() reads ev.deltaMode (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (baseY === 0), the gesture becomes coalesced PageUp/PageDown key sends (_maybePageCliTranscript) instead of a no-op; ⚠️ and getClaudeCliVersion() must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). _logScrollRouting() prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding
Detached start + service install (issue #231): codeman web -d relaunches the SAME entry script with detached:true (setsid), so there is no controlling terminal and no shell job entry. ⚠️ nohup is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and cli.ts handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both -d and service install must REFUSE when a server is already up on this data dir (pidfile check + /api/status probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls /api/status until the child answers or dies, since launchctl load and a clean spawn are both silent about a server that starts and immediately exits. --stop verifies the pid still LOOKS like a Codeman server (ps -o command=) before signalling, because pids get recycled. Unit/label names live in config/service-names.ts so install.sh, detectSupervisor() and service install cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. service install bakes the installing shell's PATH into the unit (launchd gives a job /usr/bin:/bin:/usr/sbin:/sbin, which finds neither a Homebrew/nvm node nor tmux/claude) 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 supervised by systemd/launchd (systemd, launchd, launchd-daemon, else none → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED scripts/self-update.sh that outlives the restart and writes progress to update-status.json, which the browser polls across the connection drop. src/web/self-update.ts splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → architecture-invariants#self-update
Attachments (live external document references; all wiring in file-routes.ts): a registry maps a stable attachmentId to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The magic-link scanner (codeman://attach?... in terminal output) is prompt-injectable, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension allowlist, not a blocklist. document-conversion-limiter.ts caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → architecture-invariants#attachments
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 the response viewer's _linkifyFilePaths(); a fresh instance per call, since lastIndex is per-object state. The chat linkifier walks TEXT NODES with DOM APIs (the source is model output; never rebuild sanitized markup as a string) and skips subtrees already inside an <a>. ⚠️ An out-of-workspace path is served through the ATTACHMENT routes, not the file routes — file-content/file-raw are workspace-confined and 404 exactly the paths agents print most (a /tmp capture, Claude's scratchpad), so openFilePreview() registers such a path via POST /api/sessions/:id/attachments with notify: false (suppresses only the attachment:detected broadcast — same guard, same routes; without it every click also popped a card announcing the file already on screen) and renders by id. The click is an explicit action on the explicit, Origin-guarded route, which is what distinguishes it from the force-confined magic-link scanner. ⚠️ Media extensions are single-sourced (VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS in attachment-registry.ts, imported by file-content's classification) so a clip plays the same in or out of the workspace; a player needs all THREE of allowlist + a real MIME_TYPES entry (octet-stream renders a dead player) + the range-aware body. ⚠️ TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (never a second list): if the viewer would edit it inside the workspace, it can be read outside. Widening READ must never widen RUN, so html/htm joined svg in serveRawFile's download-only branch, other text goes out as inert text/plain+nosniff, and ~/.codeman*/state.json joined isSensitivePath (it persists envOverrides, which can hold GEMINI_API_KEY). ⚠️ The terminal sends an out-of-workspace path to the preview instead of the log viewer (that one spawns tail -f and reaches only workspace + /var/log + ~/logs); in-workspace text keeps the tail viewer and file-stream-manager's allowlist is untouched. The image-watcher keeps its own narrow detection list, so none of this cards every file an agent writes. → 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
Raw file bodies are streamed and range-aware: file-raw and the attachments /raw route always advertise Accept-Ranges: bytes and answer a Range header with 206 + Content-Range (single-range only; parser is pure + unit-tested in src/web/http-range.ts, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ A 200-only response is what made the File Viewer's <video> unseekable: Chrome then reports video.seekable as [0, 0], the scrub bar is inert and currentTime = x silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through reply.hijack(), which bypasses Fastify's status handling — sendRawStream must copy the status onto reply.raw by hand or a partial body ships labelled 200 and the browser treats a slice as the whole file. ⚠️ Closing the preview must pause and unload the media (_stopFilePreviewMedia in panels-ui.js): dropping the overlay's visible class is display:none and nothing else, and a DETACHED HTMLMediaElement keeps playing, which is how the X button used to leave a video audible with no player to pause.
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 a public repo into the caller's case space synchronously (request held open, bounded by GIT_CLONE_TIMEOUT_MS, no job store); POST /api/cases/clone-preflight reports whether the URL can be cloned anonymously plus its real branches/tags. Core in src/git-clone.ts. ⚠️ The URL is a code-execution surface: ext::sh -c <cmd> (and ANY <name>::<payload> helper) makes git run a command, so every :: form is refused, a leading - is refused, and every spawn is an argv array with -- before the operands. ⚠️ Non-interactive or the open request hangs — gitNonInteractiveEnv() closes the terminal/askpass/ssh/GCM prompt paths; HOME/PATH stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a user:password@ URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing CLAUDE.md kept, hooks merged, repo-shipped .claude/settings* reported as a warning since its hooks run locally). The Brain picker sets the toolbar run mode on success. → 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 agent sessions. NOT a sixth SessionMode (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are proxied through Codeman's own origin by default, because a direct iframe fails three ways at once: prod is HTTPS so http:// targets are blocked as mixed content, many dashboards send X-Frame-Options: DENY, and our own default-src 'self' CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (/webview/... is 'self'). ⚠️ The proxy is NOT an API surface: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-/api paths and is pinned by test/webview-auth-exemption.test.ts. ⚠️ Iframes omit allow-same-origin unless a dashboard is explicitly marked trusted, and Authorization/codeman_session are stripped upstream in both modes so CODEMAN_PASSWORD cannot leak. ⚠️ A sandboxed frame is opaque-origin, which breaks two things curl can never reproduce: its runtime-built root-absolute URLs escape <base> (fixed by an injected runtimeUrlShim()), and its same-host fetch/XHR are CORS-checked with Origin: null (fixed by buildProxyCorsHeaders() plus exempting the proxy from the global OPTIONS-204 short-circuit in registerSecurityHeaders). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → architecture-invariants#web-tabs, docs/web-tabs.md
Multi-user mode (opt-in --multiuser / CODEMAN_MULTIUSER=1, OFF by default): named users with scrypt-hashed passwords in ~/.codeman/users.json. Gated everywhere by isMultiUserMode(); when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ Not a security boundary at the agent layer: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through Session.owner and is enforced in findSessionOrFail, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → architecture-invariants#multi-user-mode, docs/multi-user-plan.md
Away digest: GET /api/away-digest aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in web/away-digest.ts. ⚠️ Returns {success:true,digest}, a legacy raw-ish shape consistent with the other raw GET handlers in system-routes.ts; frontend and tests read .digest. → architecture-invariants#away-digest
Ralph todo-config: per-session maxTodos (FIFO-eviction cap, default 500 = MAX_TODOS_PER_SESSION) + todoExpirationMinutes (auto-expiry, default 60) set via POST /api/sessions/:id/ralph-config (RalphConfigSchema, both .int().positive()). Stored on the tracker (setMaxTodos/setTodoExpirationMinutes) and persisted/read-back via RalphTrackerState (surfaced in the loopState getter → toState() + SSE broadcast → modal populateRalphForm), mirroring how maxIterations round-trips. Claude-only (skipped by isExternalCliMode).
Port interfaces: Routes declare dependencies via port interfaces (src/web/ports/). Routes use intersection types (e.g., SessionPort & EventPort).
Frontend
Frontend JS modules have @fileoverview with @dependency/@loadorder tags. Load order: constants.js(1) → i18n.js(1.5) → mobile-handlers.js(2) → voice-input.js(3) → notification-manager.js(4) → keyboard-accessory.js(5) → input-cjk.js(5.5) → sanitize-html.js(5.6) → app.js(6) → terminal-ui.js(7) → respawn-ui.js(8) → ralph-panel.js(9) → orchestrator-panel.js(9.5) → cron-ui.js(9.7) → settings-ui.js(10) → panels-ui.js(11) → readmymind-ui.js(11.3) → ultracode-panel.js(11.5) → approvals-ui.js(11.6) → admin-ui.js(11.7) → session-ui.js(12) → 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).
Entrance animations (entrance-animations.js, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via data-tab-anim / data-term-anim / data-win-anim / data-line-anim on <html>. Defaults are the legacy theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are destroyed mid-animation on every re-render (_fullRenderSessionTabs() replaces the strip's innerHTML; _updateConnectionLinesImmediate() does svg.innerHTML = ''), so both are tracked by id and re-applied to the fresh element with a negative animation-delay to resume rather than restart. ⚠️ The terminal-pane styles may animate transform / opacity / clip-path only, xterm's FitAddon derives rows+cols from getComputedStyle(parent).width/height, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than beam transform the window, which moves the rect its connection line is aimed at; beam deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own codeman:*Anim localStorage keys (per-device, deliberately NOT in the .strict() SettingsUpdateSchema); picker in App Settings → Appearance, full per-surface lab at ?animlab=1.
Mobile tab strip scrolling (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: _updateActiveTabImmediate() scrolls the selected tab into view via computeTabScrollLeft() (pure, in constants.js) using rect math on the strip's own scrollLeft, never scrollIntoView(), which would also scroll the document under a fixed header; _fullRenderSessionTabs() restores scrollLeft across the innerHTML rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab only when it changed (_lastRenderedActiveTabId), so browsing the far end of the strip is not undone by background renders. ⚠️ The ACTIVE tab is the only one with action icons, and on a phone they can eat it: .session-tab.active .tab-name reserves min-width: 44px in the ≤430px block, because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ The floor is set by the 10th tab onward, not by the tabs you can see: .tab-number renders only for _tabIdx < 9, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when reserved > icons + rightEdge - leftRunUp - gap (= 50 + 9 - 17 - 4 = 38px), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. test/mobile-tab-tap-zones.test.ts recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
Session list layout: header strip or left sidebar (sessionListLayout, App Settings → Appearance → Tabs, default header; per-device policy — it IS in SettingsUpdateSchema and persists server-side, but displayKeys makes a device keep its own value): with many sessions the horizontal strip stops being scannable, so the list can move into a vertical <aside> with a filter box and a live count, collapsible to a 44px rail (--sidebar-width 260 / --sidebar-width-collapsed 44) via Alt+B (toggleSessionSidebar; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). ⚠️ There is ONE #sessionTabs element and it is MOVED between two hosts (#sessionTabsHost in the header, #sessionSidebarList in the aside), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged, and applySessionListLayout() is the only thing that reparents it. ⚠️ It sets data-session-list / data-sidebar on <html> and must run BEFORE applyTabWrapSettings(), which is the one owner of tabs-two-rows/tabs-show-folder and reads those attributes. ⚠️ Leaving sidebar mode clears _sidebarFilter: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the aside is an off-canvas overlay rather than a docked rail, and a closed drawer keeps display: flex, so it is marked inert + aria-hidden (_isSessionSidebarOverlay()) or its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable. The desktop home rail (home-sessions.js) defers to it, since both dock the session list flush left.
Phone overview home screen (mobile-overview.js, phones only, per-device mobileOverviewEnabled, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in showWelcome()/hideWelcome() (terminal-ui.js) behind shouldUseMobileOverview(), which is width-driven (getDeviceType() === 'mobile') because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the hidden attribute and only this module removes it: never give .mobile-overview a bare display rule, since desktop does not load mobile.css (media="(max-width: 1023px)") and would then render it unstyled. Live re-renders ride on the tail of _renderSessionTabsImmediate() (every state change it needs already funnels there); PAST rows come from one _fetchUnifiedSessions(60) per home-screen visit and resume through the shared resumeHistorySession(), so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the toolbar's own classes (btn-toolbar btn-run mode-<backend> / btn-run-gear) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no background/color on it), and row status uses the session-tab language (green dot when fine, pulse while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring tab-alert-idle/tab-alert-action). The picker mirrors the toolbar run-mode menu (setRunMode() + run(), openWebviewFromMenu() for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry data-i18n-skip (generic words like "idle" collide with state strings elsewhere).
Desktop home tab rail (home-sessions.js, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail docked flush to the left edge, full height (a vertically centered card floating mid-gutter read as debris). Rows are in overview order (see below), and each carries a created stamp plus the state duration the order is computed from (created 3d ago · working 12m, word and anchor from _mobileOverviewSince() so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now". ⚠️ The number badge is the Alt+1..9 index, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (_mobileOverviewState/_mobileOverviewCaseFor), which is why the module loads after it. ⚠️ The rail is position: absolute so the centered content never moves, which is exactly why it needs a width gate in two places — HOME_SESSIONS_MIN_WIDTH (1180) in the JS plus a max-width: 1179px media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and test/home-sessions.test.ts pins them equal. ⚠️ .home-sessions is display: flex, so [hidden] must be re-asserted as display: none or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off one knob: width: clamp(250px, 19vw, 430px) plus a fluid font-size on .home-sessions, with every child sized in em — reintroducing rem/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed in place by a 20s clock (_tickHomeSessionsTimes(), disarmed in hideHomeSessions()), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the tab-load-spin ring reused from the tab strip + the same green halo (added to .mobile-overview-dot--working at the same time), so "working" reads the same on every surface; idle is deliberately NOT that green — dot and pill mix toward --text-muted so a glance separates running from sitting. Live re-renders ride the tail of _renderSessionTabsImmediate() alongside the phone overview.
Home-screen session order (CodemanSessionOrder in constants.js, pure + unit-tested in test/session-overview-order.test.ts): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is needs → error → waiting → working → idle → done, and ⚠️ the tiebreak flips direction halfway down: states a session is still IN sort oldest-first (blocked longest / running longest = most urgent), states it has STOPPED in sort newest-first (the session that just went quiet is the one you came back for). ⚠️ The running group keys off lastSubmitAt (the pane's last Enter), never lastActivityAt: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it. ⚠️ A 0 stamp means "unknown", not "the epoch", and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (orderIndex), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
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 (0x0B) into the PTY. ⚠️ saveAppSettings() rebuilds settings from the DOM, so keys edited elsewhere (shortcutOverrides, showTokenCount, showCost) need explicit _prev carry-over. ⚠️ Smart copy (Ctrl+C) lives in that same handler: with a selection it copies, with none it must return true without preventDefault() or the interrupt is lost. copyTerminalSelection is deliberately absent from SHORTCUT_ACTIONS because the generic capture loop preventDefaults every match it dispatches. → architecture-invariants#command-palette-and-shortcut-registry
Per-device vs synced settings: the displayKeys set in settings-ui.js is a client-side merge policy, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; showPlanUsageLimits is additionally deleted from the incoming payload outright. Separately, SettingsUpdateSchema is .strict() and simply does not declare skin, showFileViewerButton, showCronButton, webglRendererEnabled, localEchoEnabled, cjkInputEnabled, or extendedKeyboardBar, so sending one of those is a validation error. The rest (showResponseViewer, showPlanUsageLimits, language, and most show* keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding both questions: membership in displayKeys, and presence in the schema.
Settings surface (#appSettingsModal + #sessionOptionsModal + #createCaseModal): the set-* language (left rail, groups of rows, control pinned right) is shared by all three modals through ONE :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) scope in styles.css: an :is() list takes its most specific argument's specificity, so every rule keeps the id weight it had and nothing downstream shifts. App Settings is a rail that is a table of contents over ONE scrolling document, not a tab switcher: every section stays mounted (.set-section, ids settings-updates|terminal|layout|appearance|models|clis|notifications|voice|shortcuts|system, in that order, the version and the updater leading and the rest of the system settings tailing), and switchSettingsTab(id) keeps its historical name but SCROLLS instead of hiding. Session Options and Add Case use the same surface with a rail that really SWITCHES (switchOptionsTab / switchCaseModalTab show one .set-section and .hidden the rest, since Summary owns its own scroller, Respawn is long, and Add Case is six independent forms). ⚠️ They also take a deliberate size-up that App Settings does not (900px shell, 236px rail, height:auto between min(560px,80vh) and 88vh, vs App Settings' tight 760×620): they are short task panels, not a document you scan, and at scanning density they read as a few fields marooned in an empty frame. Those per-modal blocks are the design, not drift. Phones (≤860px) give App Settings the sticky #appSettingsJump pill and give the other two a horizontal rail strip, which neither has a pill for. ⚠️ The Session Options rail entry labelled Session still keys off context (data-tab="context", #context-tab, switchOptionsTab('context')), the rename is label-only. Add Case keeps its legacy .form-row markup (six panels of it, every id read back by session-ui.js) and is mapped onto the look by an adapter block scoped to #createCaseModal .set-doc. Do not restructure those forms just to reach the row classes. ⚠️ That adapter's summary { display:flex } kills the native disclosure triangle, so every <details> there needs the explicit .set-adv-chev and both marker suppressions (list-style + ::-webkit-details-marker); without it five collapsed blocks render as plain headings nobody clicks. ⚠️ The load/save contract is getElementById by id: openAppSettings()/saveAppSettings()/openSessionOptions() read every control by a fixed id, so moving a control between sections is free but renaming or dropping one silently stops it loading or saving. Static guards: test/app-settings-structure.test.ts + test/session-options-structure.test.ts (rail↔section pairing, one-visible-section, the data-claude-only entries external CLIs drop). ⚠️ Model cards (#appSettingsModelCards) and the effort segment are views over hidden <select>s that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes base + [1m] back into claudeModel, which is what retires the old "takes precedence over the toggle below" trap. ⚠️ .modal-tabs/.modal-tab-btn/.modal-tab-content are RETIRED: no modal uses them and their CSS is deleted, and a reappearance means a modal drifted off the shared surface. ⚠️ The Header & Panels live preview is a scale model rebuilt from the chips (_syncLayoutPreview); it owns NO icons, it CLONES .set-chip-ico out of the chip, so each icon has exactly one copy in index.html. A chip joins it via data-preview (slot) + data-preview-order, or data-preview-text for readouts that are not buttons. Its frame is painted from skin tokens only (hardcoded black alphas turned it into a grey slab on the light skins) and is data-i18n-skip. ⚠️ In Session Options → Respawn, auto-resume is a .set-callout whose <label> wraps its own switch with no for= (nesting associates them; the label+for pair has historically double-fired), and the cycle steps are real checkboxes (.set-checks), not chips. ⚠️ admin-ui.js injects the multi-user Users entry into .set-rail-items + .set-doc, so those hooks must survive any restructure. → 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
Theme skins / branding / i18n: skin selects a palette via data-skin on <html>, applied by an inline pre-paint script in index.html reading localStorage['codeman:skin'] to avoid a flash of wrong theme. ⚠️ A skin is four things that must stay in sync, and missing any one degrades silently: the html[data-skin="…"] token block in styles.css, the xterm ANSI palette in terminal-ui.js, the pre-paint allowlist, and the Settings picker (both in index.html). test/skin-themes.test.ts is the static guard. Light skins additionally need color-scheme: light and xterm minimumContrastRatio: 4.5, and applyTerminalSkin() must call the local-echo overlay's refreshFont() because it caches the terminal fg/bg. displayName changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. language (en/zh-CN) keeps English as the canonical source so live switching stays reversible. User display names flow through textContent/attribute APIs and the server title's HTML escaper, never innerHTML. → architecture-invariants#theme-skins
Foldable settings identity: responsive layout is width-driven via MobileDetection.getDeviceType(), but the localStorage namespace uses MobileDetection.isHandheldDevice() so an unfolded Android foldable keeps codeman-app-settings-mobile. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: OPPO Find N5 (unfolded) in test/mobile/devices.ts. → architecture-invariants#foldable-settings-identity
WebGL renderer toggle (webglRendererEnabled, per-device): the GPU-stall watchdog's sticky codeman-webgl-disabled marker survives page loads and is cleared only by an explicit OFF→ON save or ?webgl=force. ?nowebgl forces the DOM renderer per-load. → architecture-invariants#webgl-renderer-toggle
Shell keyboard accessory bar + one-shot Ctrl (issue #262, keyboard-accessory.js): a shell-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. setMode() now records the user's extendedKeyboardBar preference as the base layout and refreshForActiveSession() (called from selectSession) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ Ctrl is a ONE-SHOT modifier applied in terminal.onData, not in a keydown handler: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER shouldSuppressTerminalQueryResponse (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ Not every onData chunk is a keystroke, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via isTerminalFocusOrMouseReport() (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: FOCUS_ESCAPE_FILTER in session.ts strips \x1b[?1004h from every PTY read, so sendFocusMode never turns on today; if it ever did, the bar's own post-key refocus would emit \x1b[I and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ onData is not the only input path — with cjkInputEnabled on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends terminal.focus() there, which is where the bar refocuses after every key), so _handleCjkInput() applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is ctrlByteFor() (code & 0x1f over @A-Z[]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is .accessory-btn.accessory-btn-ctrl.armed (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at (0,3,1) (:is() inherits its most specific argument, and that list holds .btn-toolbar.btn-shell) — so that rule excludes the state by hand as .accessory-btn:not(.armed). Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
Dismissing the on-screen keyboard (PRs #279/#280, terminal-ui.js): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. (1) _installMobileKeyboardDismiss() — a document-level touchend that fires only while the terminal input actually holds focus, never inside #terminalContainer (tap classification owns that) and never on a control (MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, matched with closest() so an icon inside a button counts). Session tabs are covered by the selector's [tabindex]:not([tabindex="-1"]) arm, which is what stops a tab tap from blurring and then being re-focused by selectSession(). (2) In _handleMobileTerminalTap, a second tap on inert content (startedWithTerminalFocus) blurs instead of re-focusing. ⚠️ Scoped to content on purpose: the prompt row (input) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via _isActionableMobileTerminalTap. ⚠️ A scroll ends in touchend too — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from touchstart and multi-touch is never a tap. Both classifiers MUST share one threshold: initTerminal's TAP_THRESHOLD reads MOBILE_KEYBOARD_DISMISS_TAP_SLOP, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ The gate excludes test/mobile/**, so CI cannot see the only test covering (1) — run npm run test:mobile -- test/mobile/keyboard.test.ts by hand and diff the FAIL list against master. (Not npm test --: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
Phone toolbar: Enter replaces Shell (post-1.8.0): inside @media (max-width: 430px) btn-shell is display:none and btn-enter takes its slot (order: 4); starting a shell moved into the Run dropdown (Terminal / Shell → setRunMode('shell') → run() → runShell(), button label "Run SH"). runMode is z.string().max(20) server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
⚠️ sendEnterKey() MUST go through terminal._core.coreService.triggerDataEvent('\r', true) — not sendInput(), and never a raw POST to /api/sessions/:id/input. localEchoEnabled defaults to MobileDetection.isTouchDevice(), so on every phone the characters you type are buffered in the LocalEchoOverlay and have never reached the PTY; the onData Enter branch in terminal-ui.js is what flushes pendingText first and only then sends \r (after an 80ms delay so text lands first). Sending a bare \r submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. KeyboardAccessory.sendKey() is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
⚠️ Skin overrides outrank plain class rules. styles.css nests its skin block inside html:not([data-skin="og"]) { … }, so a bare .btn-toolbar rule in there resolves to specificity (0,2,1) and beats a .btn-toolbar.btn-x rule (0,2,0) in mobile.css regardless of load order. Toolbar-button colors set from mobile.css therefore need !important — that is why mobile.css leans on it so heavily. Symptom: only your !important properties land and everything else silently renders in generic toolbar grey.
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 that stops delivering does not always error, so onerror never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE comment (:keepalive), and comments are invisible to EventSource by spec, so there was nothing a client could observe: it is now the named sse:heartbeat event (cleanupDeadClients(), sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged only while the status is connected and the device is online; that guard is the loop breaker, since a forced connectSSE() leaves connected immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside addListener itself, so every registered handler (the _SSE_HANDLER_MAP wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists only to be registered, since EventSource drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of connectSSE() and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs handleInit → _resetAllAppState(). The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
Z-index layers: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, mobile.css), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
Respawn presets: solo-work (3s/60min), subagent-workflow (45s/240min), team-lead (90s/480min), ralph-todo (8s/480min), overnight-autonomous (10s/480min).
Keyboard shortcuts: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.
Security
Full model: docs/security-architecture.md (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). Layer-by-layer detail with the history behind each: architecture-invariants#security-layers.
| Layer | The rule |
|---|---|
| Auth | Optional HTTP Basic via CODEMAN_USERNAME (default admin) / CODEMAN_PASSWORD. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts) |
| Network bind | Defaults to loopback. Non-loopback without a password starts but warns loudly. Classifier: network-auth-policy.ts |
| Host guard | Always-on Host-header allowlist blocking DNS rebinding. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix |
| CSRF / Origin | Always-on cross-site Origin guard on state-changing requests. A missing Origin is allowed so curl/CLI and hooks keep working. ⚠️ The body parser keeps text/plain RAW; auto-JSON-parsing it enabled simple-request CSRF |
| QR Auth | Single-use 6-char tokens (60s TTL) for tunnel login. See docs/qr-auth-plan.md |
| Sessions | 24h cookie (codeman_session), auto-extend, device context audit |
| Rate limit | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
| Hook bypass | /api/hook-event + /api/status-telemetry skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires X-Codeman-Hook-Secret unconditionally (Codeman cannot detect a user's own loopback reverse proxy) |
| Tunnel | Enabling a tunnel refuses without CODEMAN_PASSWORD unless exposure is acknowledged via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 or the per-request acknowledgeUnauthTunnel:true action field (never persisted) |
| Validation | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*) |
| Headers | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
Security-relevant env vars: CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway).
SSE Event Registry
155 event constants in src/web/sse-events.ts (backend) and SSE_EVENTS in constants.js (frontend). Both must be kept in sync, and test/sse-registry-parity.test.ts is the guard that pins it (currently exactly in sync, 155 = 155, no drift either direction). The backend file's @fileoverview carries the per-category breakdown.
API Routes
~217 handlers across 24 route files in src/web/routes/: system (48), sessions (34), cases (29), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the /webview/:cap/* proxy), mux (5), push (4), scheduled (4, legacy ScheduledRun), approvals (4), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the /ws/voice/stream relay), ws (1 WebSocket). Each file has @fileoverview with endpoint details.
HTTP contract (stable since 0.9.x, see docs/versioning-policy.md; full envelope/status/error-code/SSE spec in docs/api-reference.md): responses use the ApiResponse<T> envelope — { success: true, data? } or { success: false, error, errorCode } (src/types/api.ts). /api/v1/* is a versioned alias of /api/* (URL rewrite in server.ts).
Adding Features
- API endpoint: Types in
src/types/domain file, route 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), mux-sessions.json (tmux recovery), settings.json (user prefs), push-keys.json + push-subscriptions.json, session-lifecycle.jsonl (audit log), update-status.json (self-updater progress, polled across the service restart), linked-cases.json, webviews.json (saved web-tab dashboard URLs), remote-hosts.json + remote-cases.json, docker-hosts.json + docker-cases.json + docker-exports/, subagent-window-states.json + subagent-parents.json (subagent window layout, GET/PUT /api/subagent-window-states/-parents), hook-secret (per-instance), users.json (multi-user, mode 0600) + admin-audit.jsonl, 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). 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 env var, set automatically), TmuxManager no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (IS_TEST_MODE in src/tmux-manager.ts). Every docker IO path is no-op'd the same way. Session is test-gated too: instead of attaching a real tmux client, it spawns a raw-mode echo PTY (TEST_PTY_SCRIPT in src/session.ts), so integration tests get a live input/output loop that echoes each byte exactly once. test/setup.ts gives every test file a temporary HOME/USERPROFILE (all homedir()-derived state, ~/.codeman and ~/codeman-cases included, resolves into a per-file fixture; the Playwright browser cache path is preserved), and additionally strips CODEMAN_PASSWORD/CODEMAN_USERNAME (so auth state from the running instance can't leak into tests) and CODEMAN_GESTURE (a shell-exported gesture flag would flip render-injection assertions). ⚠️ Raw npx vitest without --config skips setup.ts and with it the temp-HOME isolation.
Ports: Pick unique ports manually, 3150+. Search const PORT = before adding new tests. Never 3000 (the live instance).
⚠️ Browser tests can pass vacuously on mobile input paths. Two traps, both hit on 2026-07-27 while fixing the phone Enter button: (1) driving input with app.sendInput('…') writes PAST the LocalEchoOverlay, so pendingText stays empty and any overlay bug is invisible — type with page.keyboard.type() instead; (2) headless Chromium reports MobileDetection.isTouchDevice() false even with hasTouch: true, so _localEchoEnabled is off and the local-echo branch never executes. Force it (app._localEchoEnabled = true) or the test proves nothing. Assert on real state (app._localEchoOverlay.pendingText, plus tmux -L codeman capture-pane -p -t <pane> for what actually reached the PTY), not on HTTP 200.
Testing against the live instance: prod is HTTPS-only on :3000 (curl -sk https://localhost:3000/...). ⚠️ w1/w2/w3 are the user's REAL sessions — never send input to them. Create your own throwaway session (POST /api/sessions then POST /api/sessions/:id/shell; creation alone leaves pid: null and no pane), test against that, and DELETE it by exact id when done.
Respawn tests: Use MockSession from test/mocks/index.ts (defined in test/mocks/mock-session.ts). Route tests: app.inject({ method, url, payload }) in test/routes/ — no live port needed. Mobile tests: Playwright suite in test/mobile/ (136 device profiles). Browser-testing infra and practices: docs/browser-testing-guide.md.
Debugging
tmux list-sessions # List tmux sessions
curl localhost:3000/api/sessions | jq # Check sessions
curl localhost:3000/api/status | jq # Full app state
curl localhost:3000/api/subagents | jq # Background agents
cat ~/.codeman/state.json | jq # Persisted state
Mobile screenshots: ~/.codeman/screenshots/, accessed via GET/POST /api/screenshots.
Performance & Limits
Target: 20 sessions, 50 agent windows at 60fps. Limits live in src/config/ (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable.
Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is clamped to ≤75% of max, because a trim ≥ max would disable BufferAccumulator trimming entirely and make memory unbounded; and browser xterm scrollback is a separate hardcoded 50k (DEFAULT_SCROLLBACK in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. 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, 92KB) is the public entry point: curl -fsSL <raw url> | bash installs Node/tmux if missing, clones to ~/.codeman/app, builds, and offers a systemd/launchd service. The network-access prompt is 3-way: Tailscale (loopback bind + guided tailscale serve --bg <port> HTTPS setup: install/login/operator/tailnet-HTTPS-toggle, then curl-verified end-to-end), LAN (0.0.0.0 + password prompt), or local-only; it preserves the existing binding on re-runs via read_existing_binding(). Tailscale state is detected dynamically from tailscale serve status --json (no marker files); the installer must NEVER tailscale serve reset or touch serve mappings other than 443→Codeman's port (users have unrelated serve config). install.sh update, install.sh uninstall, and install.sh tailscale (retrofit Tailscale access onto an existing install) also exist; CODEMAN_NONINTERACTIVE=1 approves system changes for automation, CODEMAN_TAILSCALE=1 presets the Tailscale choice (never installs Tailscale non-interactively).
Other key scripts: scripts/tmux-manager.sh (safe tmux mgmt), scripts/tunnel.sh [quick|named] start|stop|status|url (quick = random trycloudflare URL, default; named setup|enable = fixed-hostname tunnel via scripts/codeman-tunnel-named.service; bare start|stop|url still means quick), scripts/run-beta.sh (isolated beta instance), scripts/build-agent-image.mjs (docker base image), scripts/self-update.sh (detached updater). Production services: scripts/codeman-web.service, scripts/codeman-tunnel.service. Always set CODEMAN_PASSWORD before exposing via tunnel.