- buildSplitPickerSessions() now excludes any session with pid === null (exited CLI, tripped PTY-exit breaker, a restore that never re-attached). Pane B has no equivalent of selectSession()'s auto re-attach POST, so a split opened onto one had nothing reading its tmux pane: no terminal events ever arrived and Session.write() silently dropped every keystroke with no ack either way, while the socket itself reported healthy. - Fixed the hollow chord regression test: the synthetic keydowns carried no keyCode, which is what xterm's evaluateKeyboardEvent switches on to produce a data frame at all, so the assertion held regardless of whether the gate fired. Adding real keyCodes surfaced a second, real bug in the Alt+B case: the event bubbles to app.js's own document-level shortcut dispatcher, which really toggles the sidebar and resets the layout attribute the gate reads before Pane B's own (later, non-capture) handler ever sees it — fixed by driving the app's real settings cache instead of only the DOM attribute. - Ported the two remaining primary-pane gates with real consequences: Ctrl+Z (SIGTSTP) is swallowed for every non-shell session, matching terminal-ui.js's reasoning (an Ink/TUI agent loop stops dead with no visible output otherwise), and Shift/Ctrl+Enter now POSTs to /api/sessions/:id/send-key for THIS pane's own session instead of letting xterm send a bare \r, which used to submit an incomplete prompt instead of inserting a newline. Smart-copy Ctrl+C is re-implemented against Pane B's own terminal (copying app.copyTerminalSelection() would have copied Pane A's selection instead). - Updated docs/architecture-invariants.md and docs/split-pane-sessions-plan.md to match, and added CLAUDE.md's missing .split-picker-menu z-index entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
250 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Deep implementation detail lives in
docs/architecture-invariants.md. This file holds the rules that prevent mistakes; that file holds the mechanisms, file inventories, and the history behind each rule. Pointers below are written as→ architecture-invariants#anchor. When the goal is raw throughput,docs/SPEEDRUN.mdis the fast-execution protocol (it removes ceremony, never the safety rules here). The user-facing manual isdocs/wiki/(mirrored to the GitHub wiki by CI; see the CI note under Additional Commands), andAGENTS.mddeliberately just points here.This file is in
.prettierignoreon purpose. Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (agent-*.jsonlbecameagent-\_.jsonl, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not runprettier --writeon it.Repo root is kept short on purpose (the README sits below the file listing on GitHub). Config lives in
config/(eslint.config.js,knip.json, the vitest configs), Prettier's config is the"prettier"key inpackage.json, andSECURITY.mdis under.github/. Root-only files are the ones tools genuinely require there:CLAUDE.md+AGENTS.md(loaded from the root by Claude Code / Codex),CHANGELOG.md(changesets writes it next topackage.json),tsconfig.json,.editorconfig,.nvmrc/.npmrc,.prettierignore(resolved relative to cwd),LICENSE(GitHub detection),.dockerignore(the build context is the repo root, so Docker resolves it there and nowhere else) andinstall.sh(its raw URL is the published install one-liner)..claude-plugin/marketplace.jsonis root-only for the same reason:/plugin marketplace add Ark0N/Codemanreads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists isplugins/codeman/(manifest + README + a MIRROR ofskills/codeman/), and ⚠️ the plugin is a small separate directory on purpose:claude plugin installcopies the plugin root into its cache, and a plugin root that carries apackage.jsongets an npm install at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink toskills/codemanwould dangle in the copy.skills/codeman/stays the single source;scripts/sync-plugin.mjsmirrors it and writespackage.json's version into both manifests insideversion-packages, andtest/plugin-manifest.test.tspins the byte-identity, the versions, the absence of apackage.jsonin the plugin root and that no other component (commands/,agents/,hooks/,.mcp.json,settings.json) rides along.npm run check:pluginruns that drift check plus both strict validations with EXPLICIT paths (plugins/codeman,.claude-plugin/marketplace.json): a bare.argument copied out of prose reads as a full stop and gets dropped, which surfaces asmissing required argument 'path'. Needs theclaudeCLI, so it is a local check, not a CI step. Don't relocate those.
Quick Reference
| Task | Command |
|---|---|
| Dev server | npm run dev (or npx tsx src/index.ts web) |
| Type check | npm run typecheck (= tsc --noEmit) |
| Lint | npm run lint (fix: npm run lint:fix) |
| Format | npm run format (check: npm run format:check) |
| Tests | npm test (the CI gate — safe to run bare) · one file: npm test -- test/<file>.test.ts · see Testing for the excluded suites |
| Build | npm run build (esbuild via scripts/build.mjs, NOT tsc — tsc --noEmit is type-check only) |
| Production | npm run build && systemctl --user restart codeman-web |
CRITICAL: Session Safety
You may be running inside a Codeman-managed tmux session. Before killing ANY tmux or Claude process:
- Check:
echo $CODEMAN_MUX- if1, you're in a managed session - NEVER run
tmux kill-session,pkill tmux, orpkill claudewithout confirming - Use the web UI or
./scripts/tmux-manager.shinstead of direct kill commands
The working tree is shared with other agent sessions. Several Codeman sessions run against THIS one checkout, so another session can git checkout a different branch, or leave half-finished untracked files, while you are mid-task.
- Always
git branch --show-currentimmediately before committing. Observed 2026-07-27: another session rangit checkout -b feat/web-tabs, a commit silently landed there instead of master, and the follow-upgit push origin mastercheerfully reported "Everything up-to-date". - To land a commit on master without switching branches (which would yank the tree out from under the other session):
git push origin HEAD:masterthengit branch -f master HEAD. Nevergit checkout masterto "fix" it. - Never
git add -A/git add .— stage explicit paths. A sweep will pick up another session's WIP. - Another session's broken WIP can block
npm run build, sincetscis the first step and the build gates on it. That is not your bug to fix. ⚠️tscstill EMITS on type errors, so a failednpm run buildleaves a rebuiltdist/index.jscompiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blockedtsc, run the asset stage ofscripts/build.mjs(everything after thetsc/chmodlines is independent of it).
CRITICAL: Always Test Before Deploying
NEVER COM without verifying your changes actually work. For every fix:
- Backend changes: Hit the API endpoint with
curland verify the response - Frontend changes: Use Playwright to load the page and assert the UI renders correctly. Use
waitUntil: 'domcontentloaded'(notnetworkidle— SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values - Only after verification passes, proceed with COM
The production server caches static files for 1 year, immutable (maxAge: '1y' in server.ts). To avoid stale frontend after a deploy, renderIndexHtml runs cacheBustAssets(html) — it appends ?v=<mtime> to every same-origin .js/.css reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because index.html is served no-cache, a normal reload now picks up edited modules/styles — no hard refresh needed (the gesture bundle is injected separately with its own ?v=). If you add an asset referenced by an absolute URL or from JS rather than a <script>/<link> tag, it won't be auto-busted. ⚠️ index.html itself is the exception: it is read ONCE into indexHtmlTemplate in the WebServer constructor, so editing markup in dev needs a server restart (edited .js/.css do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
COM Shorthand (Deployment)
Uses Semantic Versioning (MAJOR.MINOR.PATCH) via @changesets/cli. What SemVer actually covers (the CLI, documented env vars, and the HTTP/SSE API under /api/v1: endpoint paths, response envelope, errorCode values and SSE event names are public/stable; on-disk state, internal TS modules, and experimental features are internal/unstable) is defined in docs/versioning-policy.md. Third-party integration surfaces are documented in docs/extending-codeman.md. Security reporting + known limitations live in .github/SECURITY.md.
When user says "COM":
-
Determine bump type:
COM= patch (default),COM minor= minor,COM major= major -
Create a changeset file (no interactive prompts). Write a
.mdfile in.changeset/with a random filename:cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET' --- "aicodeman": patch --- Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag) CHANGESETReplace
patchwithminorormajoras needed. Include"xterm-zerolag-input": patchon a separate line if that package changed too. -
Consume the changeset:
npm run version-packages(auto-bumpspackage.jsonfiles, updatesCHANGELOG.md, runsnpm install --package-lock-only, and verifies lockfile sync viascripts/check-lockfile-sync.mjs— all in one command; never hand-editCHANGELOG.mdorpackage-lock.jsonversions) -
Sync CLAUDE.md version: Update the
**Version**line below to match the new version frompackage.json -
Commit and deploy: verify the branch first (
git branch --show-current), then stage EXPLICIT paths — nevergit add -A, which has swept another session's WIP into a release.git status --shortand account for every line before committing:git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web -
Refresh the getcodeman.com version badge: the landing page's status bar carries the release version (
v<x.y.z> · getcodeman.com · MIT), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip andsitemap.xmlrefresh that belong in the same pass. Poll production (curl -s https://getcodeman.com/ | grep v<x.y.z>) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so. -
Wait for CI: after
git push, TWO workflows fire per master push —CIandRelease(the npm publish + GitHub release). List both runs for the pushed commit withgh run list --commit $(git rev-parse HEAD) --json databaseId,workflowNameand watch EACH withgh run watch <id> --exit-status. Confirm both pass before considering the release done (gh run list -L 1returns only one of the two). -
Announce it in Discussions: every release gets a post in the Announcements category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with
gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'(the repo's node id and its Announcements category id;pinDiscussiondoes not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
CI runs npm run check:lockfile on every push/PR, so lockfile drift fails the build even if the version-packages script is bypassed.
Version: 1.31.0 (must match package.json)
Project Overview
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
Tech Stack: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (agy, Google), Pi (pi.dev), Grok Build (grok, xAI), DeepSeek Harness (dsh) and OMP (omp) CLIs via pluggable CLI resolvers (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp').
TypeScript Strictness (see tsconfig.json): noUnusedLocals, noUnusedParameters, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch, allowUnreachableCode: false, allowUnusedLabels: false.
Requirements: Node.js 22+, Claude CLI, tmux
Git: Main branch is master. Terminal session dashboard: codeman tui (--list to list, codeman tui <n> to attach).
Additional Commands
npm run dev = dev server. Default port: 3000 (override with --port or the CODEMAN_PORT env var). To run this beta isolated alongside a prod Codeman, use scripts/run-beta.sh (sets CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000). Commands not in Quick Reference:
| Task | Command |
|---|---|
| Terminal dashboard | codeman tui (--list prints the numbered list and exits, codeman tui <n> attaches to row n; both short-circuit before any screen setup). Needs a TTY; without a server it starts attach-only. docs/tui.md |
| Dev with TLS | npx tsx src/index.ts web --https |
| Override window title hostname | npx tsx src/index.ts web --title-hostname <name> (default: os.hostname() — codeman:<name> is used for tab title, title-flash, and OS desktop notification prefix) |
| Bind a non-loopback host | npx tsx src/index.ts web --host 0.0.0.0 (or -H; env CODEMAN_HOST; default 127.0.0.1). Without CODEMAN_PASSWORD it starts but warns loudly — see Common Gotchas + docs/security-architecture.md |
| Mount under a reverse-proxy sub-path | npx tsx src/index.ts web --base-url /codeman (env CODEMAN_BASE_URL; default /). Normalized in src/config/base-path.ts ('' = root). See Reverse-proxy base path below + docs/wiki/Remote-Access.md |
| Continuous typecheck | tsc --noEmit --watch |
| Watch-mode test | npm run test:watch -- test/<file>.test.ts (runs the CI gate's config; pass a file to narrow it) |
| Test coverage | npm run test:coverage |
| Dead-code sweep | npm run knip (config in config/knip.json, passed via --config) |
| Rebuild gesture overlay | npm run build:gesture (esbuild packages/gesture-control/src/codeman/entry.ts → src/web/public/gesture/gesture-codeman.js; commit the result) |
| Regenerate the CLI catalogue | npm run generate:cli-catalog (--check to fail on drift). Rewrites config/clis.stock.json and the marked block in install.sh from stock.ts. ⚠ Commit both. They are what install.sh and the Docker agent image read, since neither can import TypeScript; test/cli-catalog-sync.test.ts and a CI --check step fail if either goes stale. See docs/cli-registry.md |
| Build the docker agent image | node scripts/build-agent-image.mjs --no-cache (builds codeman/agent:base from docker/agent.Dockerfile; prerequisite for Docker cases; --engine/--image). ⚠ Always --no-cache — a plain rebuild re-uses the cached npm install -g layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See docs/docker-cases.md |
| Gesture playground | npm run dev in packages/gesture-control/ (standalone vite demo, fake tabs) |
| Check public-asset formatting | npm run check:public-assets (prettier-checks src/web/public/** text assets; scripts/check-public-assets.mjs) |
| Frontend JS syntax check | npm run check:frontend-syntax (scripts/check-frontend-syntax.mjs; runs in CI) |
| Excluded-suite runners | npm run test:browser · npm run test:mobile · npm run test:perf · npm run test:all (everything, environmental failures included) — see Testing |
| Production start | npm run start |
| Production logs | journalctl --user -u codeman-web -f |
| Detached server | codeman web -d (--status, --stop; pidfile+log at dataPath('web.pid'/'web.log')). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
| Install/remove the service | codeman service install / status / uninstall (systemd user unit on Linux, LaunchAgent on macOS; names from config/service-names.ts) |
| Dependency doctor | codeman doctor (alias check-deps; --json, --category core|office|other). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against config/dependency-registry.ts; engine is pure given an injectable ProbeHost |
| Multi-user accounts | codeman users add <name> / passwd <name> / list / rm <name> (writes ~/.codeman/users.json, mode 0600; see Multi-user mode) |
CI: .github/workflows/ci.yml (push to master/main + PRs, Node 22) runs two jobs: (1) check:lockfile, typecheck, lint, check:frontend-syntax, format:check, then a server boot smoke test (tsx src/index.ts web --port 3151 must answer /api/status within 30s); (2) the unit/integration test suite via npm run test:ci (config/vitest.ci.config.ts — excludes the browser-driven test/mobile/** suite, perf-* benchmarks, and 9 Playwright tests; globs live in config/test-suites.ts), followed by the packages/xterm-zerolag-input package tests (a bare npx vitest run in that directory; its vitest is hoisted by the root npm ci, so no separate install, and npm test at the root does NOT run them). npm test runs this same config, so local green == CI green. Tests are tmux-safe in CI: TmuxManager no-ops all shell commands under VITEST (see Testing). A third workflow, wiki-sync.yml, fires only on master pushes touching docs/wiki/** and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via docs/wiki/).
Code style: Prettier (singleQuote: true, printWidth: 120, trailingComma: "es5") — config lives in the "prettier" key of package.json, not a .prettierrc (keeps the repo root short; editors read it natively). .prettierignore stays at the root because Prettier resolves it relative to cwd. ESLint flat config (config/eslint.config.js) allows no-console, warns on @typescript-eslint/no-explicit-any. Ignores: app.js, scripts/**/*.mjs, src/web/public/vendor/**, scripts/remotion/**.
Prettier scope is deliberately narrow. npm run format globs only src/**/*.ts and src/web/public/** (lint only src/**/*.ts), and .prettierignore then exempts most of src/web/public/*.js (app.js, styles.css, mobile.css, index.html, upload.html, and 15 hand-formatted modules) plus CLAUDE.md. Those files are hand-formatted by design; npm run check:public-assets and check:frontend-syntax are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
Common Gotchas
- Single-line prompts only —
writeViaMux()sends text+Enter separately; multi-line breaks Ink. ⚠️ Input must END with\ror Enter is never sent:sendInput()only issuessend-keys Enterwhen the payload contains a carriage return, a\r-lessPOST /api/sessions/:id/inputstill succeeds (send-and-wait even reportsdelivered:true) while the text sits unsubmitted on the composer, and anywaitburns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so"echo A\necho B\r"runs the joinedecho Aecho B. ⚠️ Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+\rsent at readiness sits unsent with0 tokensand awaitburns its timeout. So the SERVER verifies every programmatic write that carried a\r:SubmitVerifier(session-submit-verifier.ts, armed fromwriteViaMux) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's ownpromptGlyph) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill'ssendwaitkeeps its own copy of the loop (_composer_textinskills/codeman/preamble.sh) for servers that predate this. Theshift+tabfooter only means the composer painted, never that Enter is accepted - ESM only — Never
require(), useawait import().tsxmasks CJS/ESM issues in dev but production breaks - Package ≠ product name — npm:
aicodeman, product: Codeman. Release renames tags accordingly. Bothaicodemanandcodemanbin aliases are installed (package.jsonbin) - Global regex
lastIndex— Sharedg-flag patterns in loops must resetlastIndex = 0first, or use theexecPattern()helper inutils/regex-patterns.ts(resets automatically) envOverridesflowCLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*/PI_*/GROK_*/XAI_*/DSH_*/DEEPSEEK_*env vars, plus exact-keyCLAUDE_CONFIG_DIR— Set viaPOST /api/sessions { envOverrides }, stored onSession._envOverrides, exported bytmux-manager.buildEnvExports()at spawn time, persisted inSessionState.envOverrides. Do NOT write these to<case>/.claude/settings.local.json— that's the old path and creates UI/disk drift. (GOOGLE_*is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.)CLAUDE_CONFIG_DIR(#255, exact match viaALLOWED_ENV_KEYSinschemas.ts) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside~/.claude/projects, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinksprojectsback into the shared tree (ln -s ~/.claude/projects <configDir>/projects). ⚠️ It is also one of claude'sprivilegedEnvKeys(Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it viaenvOverridesis admin-only, and a non-granted owner's already-persistedCLAUDE_CONFIG_DIRis stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (seesession-env-clamp.ts). → architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir- Effort is NOT an env var — never carry effort as
CLAUDE_CODE_EFFORT_LEVEL: the env var hard-locks effort and blocks in-session/effortswitching (incl. ultracode). It flows as the dedicatedeffortpayload field →Session._effort→claude --effort <level>for regular levels incl.max(the settingseffortLevelkey isenum(["low","medium","high","xhigh"]).catch(undefined)—maxgets SILENTLY dropped there), orclaude --settings '{"ultracode":true}'for ultracode (rejected by--effort). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions inapplyEnvOverrides(). SeebuildEffortCliArgs()insession-cli-builder.ts, tests intest/effort-injection.test.ts - Model choice flows via
settings.local.json, NOT--modelor env — the App Settings Claude Model picker (claudeModelinsettings.json) is read bysession-ui.jsat session create (wins over the legacy 1M-Opus togglesopusContext1m/opusContext1mEnabled), sent as themodelOverridepayload field, andupdateCaseModel()(hooks-config.ts) writes/deletes themodelkey in<case>/.claude/settings.local.json. This is the intended exception to the envOverrides rule above: model legitimately lives insettings.local.json(a soft default — in-session/modelstill works); env vars do not - Multi-CLI prefix discipline — env-var prefix is CLI-specific (
CLAUDE_CODE_*vsOPENCODE_*vsCODEX_*vsGEMINI_*vsANTIGRAVITY_*vsPI_*vsGROK_*vsDSH_*) and theALLOWED_ENV_PREFIXESallowlist inschemas.tsenforces this; non-prefix exceptions are exact keys inALLOWED_ENV_KEYS(currently onlyCLAUDE_CONFIG_DIR), never a widened prefix. Gemini additionally allowlists the broadGOOGLE_*namespace (intentional: Vertex AI auth needsGOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlistsXAI_*for the same vendor-namespace reason (XAI_API_KEYis grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (ANTHROPIC_API_KEY,OPENAI_API_KEY,HF_TOKEN, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via/loginor the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dshsettings.yamlcan nominate ANY env var as a provider credential (apiKeyEnv), so only the vendor namespacesDSH_*(launcher inputs incl.DSH_PERMISSION_MODE) andDEEPSEEK_*(DEEPSEEK_API_KEY/DEEPSEEK_BASE_URL) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern:docs/opencode-integration.md,docs/pi-integration.md,docs/grok-integration.md,docs/deepseek-integration.md - Zod
.optional()rejectsnull— acceptsundefinedonly. When the frontend builds a request body withJSON.stringify, an explicitnullfield is preserved on the wire and fails validation withINVALID_INPUT. Convertnull→undefinedbefore stringifying (e.g.field: value ?? undefined), or declare the schema.nullish(). This has caused real shipped bugs twice - Local-echo overlay stays on screen: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional
totalRowsinRenderParams; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately,_shrinkPaddingToFit()(mobile-handlers.js) must never shrinkmain's padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar areposition: fixed, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests:packages/xterm-zerolag-input/test/overlay-renderer.test.ts,test/mobile-keyboard-bottom-padding.test.ts. xterm-zerolag-inputis single-source — BOTH echo addons live ONLY inpackages/xterm-zerolag-input/src/, bundled into TWO gitignored vendor files:vendor/xterm-zerolag-input.js(buffer overlay, entryzerolag-input-addon.ts) andvendor/xterm-predictive-echo.js(codex write-through, entrypredictive-echo-addon.ts) — dev byscripts/postinstall.js, prod byscripts/build.mjs.app.js/terminal-ui.js only consume them vianew LocalEchoOverlay(terminal)/new PredictiveEchoOverlay(terminal); there is no inline copy. So: change the package source, then rerun the bundle step (npm installfor dev,npm run buildfor prod). Never hand-editapp.jsfor overlay behavior, and never commit the gitignored vendor bundles. Always test on mobile after touching it. → architecture-invariants#xterm-zerolag-input-is-single-source,docs/local-echo-overlay-plan.md- Default bind is loopback-only; non-loopback without a password starts but warns — the server defaults to
--host 127.0.0.1. Binding non-loopback (--host/-H/CODEMAN_HOST) withoutCODEMAN_PASSWORDstarts anyway but prints a loud warning;--allow-unauthenticated-network/CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1acknowledges it. ⚠️ The production systemd unit passes no--host, so prod binds localhost only: reach it viatailscale serve/tunnel to127.0.0.1. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP.install.shis separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → architecture-invariants#default-bind-and-the-non-loopback-warning-path,docs/security-architecture.md - Instance isolation / multi-instance attach danger — the data dir (
~/.codeman) and tmux socket (tmux -L codeman) are PROCESS-WIDE and shared by every Codeman on the machine, derived fromCODEMAN_INSTANCEviasrc/config/instance.ts. ⚠️ A 2nd instance on the SAME socket discovers and attaches PTYs to the first instance's live sessions, resizing and mutating them.$HOMEisolation is NOT enough because tmux is system-global. To run two instances, give each a distinctCODEMAN_INSTANCE(scopes dir + socket together), or setCODEMAN_TMUX_SOCKET+CODEMAN_DATA_DIRindividually;scripts/run-beta.shdoes this for a beta alongside prod. Any new~/.codeman/...path MUST go throughdataPath(), neverjoin(homedir(), '.codeman', …), and any newtmux -Lcaller throughresolveTmuxSocketName()(both inconfig/instance.ts): the TUI shells out to tmux from a second process, and a hardcodedcodemanthere would point a beta instance at prod's panes. → architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger - node-pty's macOS
spawn-helperships without+x(issues #6, #204):node-pty@1.1.0publishesprebuilds/darwin-<arch>/spawn-helperas mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start withError: posix_spawnp failed.Linux can never reproduce it:spawn-helperis anOS=="mac"gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild,npm installneeds a C/C++ toolchain there (make,g++,python3), soinstall.shchecks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp withnot found: make. Do not drop that step. ⚠️ Look inprebuilds/<platform>-<arch>/, not justbuild/Release/, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletesprebuilds/before compiling):npm run fix:node-ptychmods every helper then proves it by really opening a PTY.spawnPtyWithHelperRepair()(utils/node-pty-repair.ts) wraps everypty.spawn()insession.tsand self-heals a broken install on the first failure. → architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable - Headless screenshots:
deviceScaleFactorMUST be 1, and write unique filenames — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still reporting nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work";scripts/capture-real-overview.mjsmints a timestamped filename per run. Seed the per-devicelocalStoragekeys (codeman:skin,codeman-font-size,codeman-app-settings) so the capture matches a real device. → architecture-invariants#headless-screenshot-capture
Import conventions: Utils from ./utils, types from ./types (barrel), config from specific ./config/* files.
Architecture
Core Files (by domain)
| Domain | Key files | Notes |
|---|---|---|
| Entry | src/index.ts, src/cli.ts, daemon-control, service-installer, config/service-names, cli-style |
The last three back web -d / service install; cli-style is the shared palette/table/spinner/confirm kit |
| TUI | src/tui/: tui-app ★ + tui-client (the only IO) over a pure core (-model, -layout, -render, -keys, -ansi, -composer, -approvals, -digest, -sse, -types) |
codeman tui, a CLIENT of the server, never a second brain. Design doc: docs/tui-plan.md; user guide docs/tui.md |
| DeepSeek | src/utils/deepseek-cli-resolver.ts, src/deepseek-status-shim.ts, src/deepseek-web-server.ts (background dsh web, not a session) |
dsh is a PROFILE LAUNCHER, not an agent; read docs/deepseek-integration.md first |
| Session | src/session.ts ★, session-manager, session-auto-ops, session-cli-builder, session-task-cache, session-order (pure), session-pty-exit-breaker, session-trust-dialog (pure), usage-limit-patterns, usage-telemetry; src/services/unified-session-service.ts |
Pure/unit-tested helpers are split out of session.ts on purpose |
| Mux | src/mux-interface.ts, src/mux-factory.ts, src/tmux-manager.ts ★, src/proc-tree.ts (pure, bounded descendant walk) |
|
| Respawn | src/respawn-controller.ts ★ + 4 helpers (-adaptive-timing, -health, -metrics, -patterns) |
Read docs/respawn-state-machine.md first |
| Ralph | src/ralph-tracker.ts ★, src/ralph-loop.ts + 5 helpers (-config, -fix-plan-watcher, -plan-tracker, -stall-detector, -status-parser) |
Read docs/ralph-wiggum-guide.md first |
| Orchestrator | src/orchestrator-loop.ts, -planner, -verifier |
Read docs/orchestrator-loop-architecture.md first |
| Cron | src/cron/cron-service.ts, cron-time.ts (pure next-run math), cron-input.ts |
Read docs/cron-discovery.md first. Distinct from legacy ScheduledRun (/api/scheduled) |
| Agents | src/subagent-watcher.ts ★, team-watcher, bash-tool-parser, transcript-watcher, workflow-run-watcher |
workflow-run-watcher is STANDALONE and never touches subagent-watcher |
| AI | src/ai-checker-base.ts, ai-idle-checker.ts, ai-plan-checker.ts |
|
| Tasks | src/task.ts, task-queue.ts, task-tracker.ts |
|
| State | src/state-store.ts, run-summary.ts, session-lifecycle-log.ts, intent-store.ts, tab-layout.ts (pure model) + -service (sole mutation boundary) + -persistence + -legacy-order |
|
| Infra | src/hooks-config.ts, push-store, tunnel-manager, image-watcher, file-stream-manager, remote-hosts + remote-reconnect + remote-wake (IO: dgram/net/child_process), docker-hosts + docker-export |
Remote/docker case overlays; see Key Patterns |
| Web tabs | src/webview-store.ts, webview-capabilities.ts, src/web/webview-proxy.ts (pure), src/web/routes/webview-routes.ts |
Dashboard URLs as tabs; NOT a SessionMode |
| Search | src/search-service.ts |
Pure in-memory core for GET /api/search |
| Attachments | src/attachment-registry.ts, attachment-magic, generated-artifact-attachments, session-attachment-history, document-preview-cache, document-thumbnailer, document-conversion-limiter, config/attachment-guard |
See Key Patterns |
| Plan | src/plan-orchestrator.ts, src/prompts/*.ts, src/templates/ (claude-md.ts + case-template.md) |
templates/ holds the CLAUDE.md scaffold generated into new cases |
| Web | src/web/server.ts ★, sse-events.ts, routes/*.ts (27 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 (~6.9K lines, core) + 35 modules + sw.js (+ voice-pcm-worklet.js, fetched from JS, not in the load order) |
See Frontend section for the load order, which is authoritative |
| Types | src/types/index.ts (barrel) → 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/ — 23 files plus the cli-registry/ subdir, no barrel (index.ts) exists; import from the specific file. ⚠️ There are TWO config/ directories: the repo-root config/ holds tooling only (ESLint, knip, the vitest configs, test-suites.ts), while runtime config lives in src/config/. Throughout this file a bare config/<name>.ts in a code context means src/config/<name>.ts.
Utilities: src/utils/ — re-exported via index. Key: CleanupManager, LRUMap (⚠ NOT in the barrel — import from ./utils/lru-map.js directly), StaleExpirationMap, BufferAccumulator, stripAnsi, Debouncer, KeyedDebouncer. Also: claude-cli-resolver/opencode-cli-resolver/codex-cli-resolver/gemini-cli-resolver/antigravity-cli-resolver/pi-cli-resolver/grok-cli-resolver/deepseek-cli-resolver/omp-cli-resolver (CLI path resolution, one per SessionMode, all nine sharing the lookup chain in cli-executable-resolver: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ pi-, grok- and deepseek-cli-resolver additionally probe the binary's identity, since pi is a generic name, grok has npm squatters, and Debian ships an unrelated dsh), file-query (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), string-similarity (fuzzy matching), regex-patterns (ANSI/token/spinner patterns), assertNever (exhaustive checks), token-validation (auth tokens), nice-wrapper (process priority), shell-resolver (⚠ resolves a real login shell for mode: 'shell'; the literal string $SHELL used to be expanded by the SERVER's shell, which is empty in a container), event-loop-monitor (a sync execSync freezes the port while the process stays alive, leaving no trace), dependency-checker + dependency-report (the codeman doctor probe engine, registry in config/dependency-registry.ts).
Data Flow
- Session spawns
claude --dangerously-skip-permissionsvia node-pty - PTY output buffered, ANSI stripped, parsed for JSON messages
- WebServer broadcasts to SSE clients at
/api/events - State persists to
~/.codeman/state.jsonvia StateStore
Key Patterns
Input: session.writeViaMux() for programmatic/curl input via tmux send-keys -l + send-keys Enter, single-line only. Interactive browser input goes through a durable exactly-once layer: a stable clientId + monotonic per-session seq persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. ws-connection-registry.ts supersedes only same-TAB reconnects, so two tabs on one session coexist. → architecture-invariants#input-delivery-and-ws-resilience
Agent wait primitives: bounded long-polls 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 are hook-driven and fire for claude and deepseek ONLY (shell installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. deepseek qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (deepseek-status-shim.ts), so its signals are definitive rather than inferred — hooksAvailableForMode() in session-wait-registry.ts is the one place that rule lives. ⚠️ 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. ⚠️ deepseek is therefore the one non-claude mode the skill drives like claude — spawn_workers alpha beta:deepseek is a mixed fleet in one call, and sendwait/last_text need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot idle report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and sendwait asks for wait:"stop,exit" rather than the default set, because that set also carries idle, which for an external CLI is inferred from output stabilization — on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with signal:"idle" on a turn with minutes left to run. The primitives are packaged as the skills/codeman agent skill: installable via codeman skill install [--case <name>] / skill uninstall, as a Claude Code plugin from the repo's own marketplace (/plugin marketplace add Ark0N/Codeman + /plugin install codeman@codeman, see the root-files note above; ⚠️ plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, codeman and codeman:codeman, measured 2026-09-14: neither shadows the other, both work, the docs tell users to pick one route), 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
Agent-created case marker (src/agent-case-marker.ts): a case directory POST /api/quick-start creates for an agent-driven spawn gets a .codeman-agent-case.json marker, so the scratch workspaces a long orchestration leaves behind (one per worker, and deleting the session does not remove them) can still be told apart from the user's real projects months later. GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is the read-only cleanup listing, adding inUse (a live session's workingDir is that case) and modifiedAt; Add Case → Manage badges each one and offers a review-then-delete sweep. The signal is the skill preamble's X-Codeman-Agent-Origin header (or an agentOrigin body field), falling back to a RESOLVED parentSessionId — nothing in the browser UI sets lineage, so a create request naming its spawning session came from an agent by construction, and that fallback is what still labels workers spawned by a stale skill copy. ⚠️ Only the branch that CREATES the directory may write it. A linked case, a cloned repo or any pre-existing path must never be labelled: the label drives a recursive-delete affordance, and mislabelling someone's repo there is the one failure mode that costs real work. POST /api/sessions takes an existing workingDir, so it writes no marker at all, by construction. ⚠️ Reading is TOTAL: anything that is not a well-formed version-1 marker (truncated write, hand-edited junk) reads as not agent-created rather than as a half-trusted entry, and deleting the file is the supported way to adopt a scratch case as a real one — which is what the note written into it tells whoever finds it. ⚠️ Removal stays on the existing DELETE /api/cases/:name, one name at a time, so there is exactly ONE recursive-delete path; the UI's sweep names every directory in its confirm and EXCLUDES an inUse case outright rather than confirming it away. ⚠️ Marker in the case dir rather than a registry under ~/.codeman: it survives a wiped data dir or a different instance, is removed by the same rm -rf that removes the case (so no stale-entry pruning), and a user who runs ls -a can see what labelled their directory. Adding the header changed the preamble, so CODEMAN_PREAMBLE was bumped (1.22.0) — a cached copy is version-checked, and forgetting the bump leaves every already-seeded agent sending the old headers. Tests: test/agent-case-marker.test.ts, test/routes/agent-case-marker-routes.test.ts.
Agent preamble cache GC: the §0 preamble seeded per claude session ($XDG_CACHE_HOME/codeman-agent-<id>.sh) is now REMOVED with the session (removeAgentSessionPreamble from _doCleanupSession, killMux only — a detach leaves the session recoverable and its agent would come back to a loader whose file we deleted) and swept at boot (pruneAgentSessionPreambles(this.sessions.keys()), once, after restore, so every session this instance owns is in the keep set). Nothing removed them before: 236 leftovers measured on a working machine, the oldest three weeks old. ⚠️ The sweep needs BOTH guards — never a live session's file at any age (the two-line loader reads it mid-run), and AGENT_PREAMBLE_MAX_AGE_MS (7d) of age on top, which is what keeps ANOTHER instance's sessions (whose ids this process cannot see) out of the blast radius. Losing one is degradation, not breakage: the §0 fallback block rewrites it. Tests live with the seed's in test/agent-skill.test.ts.
Idle detection: Multi-layer (completion message → AI check → output silence → token stability). See docs/respawn-state-machine.md.
⚠️ A ❯ sighting is NOT the end of a turn, and neither is silence. Claude redraws the composer (❯) 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. ⚠️ The composer glyph and the working line are per-CLI registry DATA (capabilities.workDetect, #385), not Claude constants: claude declares ❯ plus the pattern above, codex declares › plus [Ee]sc to interrupt, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no ❯, which was true and still left every Codex session reporting idle for its entire life. ⚠️ workingLine is config-supplied (a user clis.json can set it) and the compiled pattern runs on the PTY hot path, so it goes through compileVersionRegex() in BOTH the schema refine and _workingLinePattern(): a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
Workspace-trust dialog auto-accept (session-trust-dialog.ts, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck. ⚠️ Match the compacted SCREEN, never the stream. tmux repaints a row by writing each word and then a cursor-forward (\x1b[C) instead of a space, and Ink colours each word separately, so the wire carries I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder; stripping the escapes leaves Itrustthisfolder, because the spaces are not there to strip, they were never sent. A plain includes('trust this folder') therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. compactScreenText() removes ALL whitespace instead (plus the ESC ( B charset selects that stripAnsi does not cover, which would otherwise land inside a phrase as a literal (B), which survives both that repaint style and the spaced full-screen redraw. ⚠️ Never answer it with a blind \r. The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks exit and the pane dies (Pane is dead (status 1)) seconds after the session starts. trustDialogNextKey() reads the ❯ marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a startup-only window (TRUST_DIALOG_WINDOW_MS, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a two-marker match requiring a trust phrase AND one of the dialog's own confirm affordances (isTrustDialogScreen), and an attempt cap (TRUST_DIALOG_MAX_ATTEMPTS, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). ⚠️ It reads capturePaneText() and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago. ⚠️ The scan must schedule its own next read (_trustDialogTimer, cleared in _clearAllTimers()): it runs from the PTY onData handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing).
Process-tree walks are bounded (proc-tree.ts, pure + unit tested): collectDescendants(pid, byParent) is the ONE descendant traversal, fed by a single cached ps -eo pid=,ppid= snapshot (refreshProcSnapshot() in tmux-manager.ts: in-flight-shared, async because execSync's timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated ps, which would make whole subtrees invisible to the kill path). ⚠️ The unbounded version took a machine down (2026-07-30): it ran pgrep -P <pid> once per node and recursed with no visited set, no depth limit and no node cap, so across ~28 adopted tmux trees the fan-out exploded while each pgrep blocked in the WSL kernel reading /proc/<pid>/cgroup, ending at ~13,000 pgrep processes in D-state, a load average above 13,000, and a machine recoverable only by restarting WSL, which cost every running session. Three properties make that impossible and each has a test: a cycle terminates (a real tree has none, a stale snapshot can still produce one), depth is capped (PROC_WALK_MAX_DEPTH), node count is capped (PROC_WALK_MAX_NODES). The fourth is structural: the function takes a snapshot and cannot spawn anything at all. ⚠️ It lives in its own module because as a private method of tmux-manager.ts the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots. ⚠️ Truncation is reported through onTruncated rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
Auto-resume on usage limit (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, usage-limit-patterns.ts (pure, unit-tested) parses the reset time and SessionAutoOps arms a timer for reset+2min, then sends Esc + continue. ⚠️ Respawn cycles are blocked while paused (isLimitPaused guard in onIdleDetected), which is what prevents /clear from wiping the paused conversation. Claude-mode only. → architecture-invariants#auto-resume-on-usage-limit
Plan-usage chip (showPlanUsageLimits, per-device: desktop default ON, handhelds OFF via the mobile block in getDefaultSettings()): resolve DISPLAY ONLY through planUsageChipEnabled() in settings-ui.js, which backs the two call sites that must never disagree (the App Settings checkbox, the chip's visibility). The SAME persisted setting also doubles as the server-side telemetry COLLECTION switch — readPlanUsageTelemetryEnabled() (hooks-config.ts) reads it fresh from settings.json at every claude session create/respawn (TmuxManager.createSession/respawnPane), so it applies uniformly to every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) with no per-session state and no per-request field — a Codeman restart cannot silently kill it (there is nothing per-session to lose). ⚠️ An ABSENT key reads as ON (mirroring readWorkspaceHooksEnabled()), so the default is resolved by the READER and GET /api/settings stays a plain read that never writes: a reconcile write there ran on every page load and could replace an unreadable settings.json with a one-key file. ⚠️ A save sends showPlanUsageLimits ONLY when it FLIPS the chip on that device (planUsageCollectionFlip() in settings-ui.js): the chip defaults OFF on handhelds, so a phone saving its font size used to persist false and switch collection off for every desktop. Claude data comes from Codeman's marked statusLine.command exporter (injected as an EPHEMERAL claude --settings CLI flag, never written to disk — see resolveStatusLineCliCommand), which POSTs rate_limits to POST /api/status-telemetry, never overwrites a user's hand-authored statusLine (it WRAPS it instead — findEffectiveUserStatusLineCommand), and prints the footer through. Main Codex usage comes from a read-only host account/rateLimits/read app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's 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). ⚠️ It revives ONLY when the durable remote tmux session is verifiably still alive (remoteTmuxSessionAlive(), a has-session probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, exit) tears that session down, and isPaneDead() cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its || --resume fallback masked it). An unreachable host answers undefined, which also means do not revive. ⚠️ has-session prints NOTHING on success, so the probe is classified by EXIT STATUS (classifyRemoteAliveExit: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale true from one transport drop would revive the next clean exit. ⚠️ File reads in a remote case are the second ssh surface (#415, src/remote-files.ts): they go through buildSshConnectionArgs() as well, a browser-supplied path is only ever a shellescaped token, an unreachable host answers 502 (never 404), the size cap uses the REMOTE size, and no remote file is ever copied onto the server's disk — which is why writes, office previews and thumbnails are deliberately unsupported over ssh (the PUT guard sits BEFORE the local path validation, or a same-named local directory such as an sshfs mount takes the write). The probe's symlink resolution FAILS CLOSED (a path it cannot canonicalize is a 404, never its own unresolved string: the directory-only fallback let a notes.txt -> ~/.ssh/id_rsa link pass containment), and ssh children are BOUNDED by src/remote-ssh-limiter.ts plus one batched probe per attachment-history listing, because terminal output in a remote session is written on the remote host and a prompt-injected agent can print hundreds of codeman://attach links. The ATTACHMENT routes (a clicked path outside the case dir) go through the same layer, and which host a record is read from follows the SESSION, never the path string. ⚠️ 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
Wake-on-LAN (remote-wake.ts): an optional RemoteHost.wakeMac (Codeman builds the magic packet itself) or RemoteHost.wakeCommand (single executable path, run without a shell, takes precedence) lets the INPUT route, POST /api/sessions/:id/wake, and the user's own create/attach request (POST /api/quick-start, POST /api/sessions with attachRemoteSession, via ensureHostAwake) wake a sleeping host instead of writing into a stalled ssh pane. ⚠️ An explicit request — input, the wake button, or the user pressing Run/Attach — and NOTHING else may wake: the auto-reconnect watcher, handleRemoteSessionDropped, boot recovery and cron-service.ts have no access to the registry (a wake there would re-wake the host seconds after every suspend, and the create wake is wired in the route rather than the shared session service for exactly that reason), which test/remote-wake.test.ts asserts as two wiring guards — the second also pins that server.ts holds the registry for its LIFETIME only (drop on cleanup, stop on shutdown) and never calls a waking method. GET /api/sessions/:id/reachability merely probes and never wakes. Detection is a throttled bare TCP probe — deliberately no ServerAliveInterval, because keepalives move bytes into an idle connection every interval and that is what a byte-threshold idle detector must not read as activity. ⚠️ A host behind jumpHost/socksProxy/a ProxyCommand option is reachability-UNKNOWN (isProbeable()): the probe connects to host:port, which such a host does not answer even while ssh works, so the registry never buffers for it, never gates create/attach on it ('unprobeable'), and /reachability answers reachable: null, probeable: false — the banner keys on a PROVEN false, and the banner's 30 s poller runs only for a host with a wake target (a timer connecting to a host Codeman cannot wake is the same timer-driven traffic the keepalive rule forbids). Input arriving during a wake is buffered (a chunk over 4 KB is dropped whole, never delivered as a fragment; the route answers {buffered:true} / {buffered:true, dropped:true} so the caller can tell) and flushed in order after reattachRemote() with fromUser — a flush write that fails drops the rest (logged) rather than retaining it for a wake hours later; send-and-wait blocks instead and answers OPERATION_FAILED when the host never returns. ⚠️ In multi-user mode the attach path 403s a non-admin BEFORE the host is looked up: the wake runs an executable, and remote hosts are admin-only infra everywhere else. ⚠️ Browser keystrokes travel over the WebSocket, which deliberately does NOT pass through the registry (that is the hot path), so only the HTTP input path ever queues anything — the banner must not promise queued input for the Wake button. A request that waits on the wake (create/attach, and the button) uses the 40 s REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS, not the 90 s session default, because the dashboard's reverse proxy cuts a request at its own 60 s proxy_read_timeout. The wake fields are re-read from remote-hosts.json on recovery and, throttled+cached via RemoteWakeDeps.resolveRemote, for a LIVE session, since the persisted remote snapshot never sees a field added later. UI: the amber #hostWakeBanner (host-wake-ui.js) with Wake / "Configure WoL" → #wakeConfigModal. The remote: SSE family is session-scoped in multi-user mode; a create/attach wake names its requester (username) since it has no session yet. remote-wake.ts refuses real IO under VITEST like remote-files.ts.
Docker cases: a case can point at a container, with any of the CLI run modes running inside it. Like remote-SSH this is a LOCATION OVERLAY on cases, never a SessionMode of its own. 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. ⚠️ A case may instead ADOPT a container the user already runs (DockerCase.owned === false, mirror of remote-SSH's owned:false): Codeman only execs into it and never creates, starts, stops, restarts or removes it, so a missing or stopped container FAILS CLOSED with an actionable message instead of being fixed. Absent = owned, so existing cases are byte-identical. ⚠️ An ADOPTED container may back SEVERAL cases at different in-container directories (classifyAdoptContainerConflict in docker-hosts.ts: an exact twin on the same container AND directory is refused, an owned container still backs exactly one case, and a container another user adopted is refused), which is what the Add Case panel's "copy an existing case" picker relies on; the wire carries CaseInfo.docker.owned ONLY when false, so the picker tests === false, never truthiness. The guarantee is enforced at four independent layers because it cannot be observed by using the feature: buildDockerStopCommand/buildDockerRemoveCommand throw during pure STRING CONSTRUCTION, removeDockerContainer refuses again, drift reports "none" (an adopted container carries no codeman.confighash label, so a real comparison would 409 the launch forever), and the boot reaper skips it. ⚠️ Two lifecycle touches the original design missed and that are easy to re-introduce: the full-image export docker commits the container (refused for an adopted case) and the workspace export docker pauses it first (skipped — it freezes the owner's processes for the length of the tar). ⚠️ owned is applied AFTER dockerConfigHash, which takes an explicit field list, or every pre-existing case would trip the drift gate at once. ⚠️ Run modes for a container case come from the CONTAINER (availableModes, live-probed): gating the run menu on HOST CLIs (#201) is right for local sessions and wrong here, since a host with no claude may run a container that ships one. ⚠️ A failed probe means opposite things per ownership — for an ADOPTED case it is a fault worth reporting, for an OWNED one it is the NORMAL state before the first session (the launch chain creates the container), so treating it as a fault hid every agent mode on every freshly linked Docker case behind "start it yourself first". That is why CaseInfo.docker.owned is on the wire. ⚠️ Claude is launched WITHOUT --dangerously-skip-permissions when the container's exec user is root (Claude Code refuses the flag as root and the refusal is visible only inside the container); which flag to drop is a per-CLI fact, so it is the registry's overlays.docker.rootCommand, never a branch. ⚠️ Adoption is admin-only in multi-user mode, unlike docker-link: linking creates OUR container, whose one bind mount isWorkingDirAllowed has already confined, while an adopted container's mounts belong to its owner and one mounting / hands the adopter the host. The same reasoning admin-gates the container listing and the in-container directory browser; the preflight instead admits a non-admin for a container already linked to a case they own, because the run menu probes it for every docker case. ⚠️ 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)
Docker Compose deployment (docker/, contributed): Codeman itself runs in a container and spawns Docker cases as SIBLING containers through the mounted host socket (Docker-outside-of-Docker), never nested. That inverts one assumption the bare-host path takes for granted: the daemon no longer shares Codeman's filesystem, so a bind source valid inside Codeman means nothing to it. resolveDockerDaemonMountSource() translates sources under HOME into the daemon's namespace via CODEMAN_DOCKER_HOST_HOME, and CODEMAN_CASES_PATH points the cases dir at a host-absolute bind mount so a workspace resolves to the SAME absolute path on both sides (which is what keeps the transcript projHash matching, per Docker cases above). ⚠️ CODEMAN_CASES_PATH must move every consumer or none: it is resolved once in config/cases-dir.ts because src/cli.ts resolves case paths too, and when only the server's CASES_DIR learned the override, codeman skill install --case <name> reported "Case not found" on exactly the deployment the override exists for. ⚠️ .dockerignore patterns match the WHOLE context-relative path, so a bare .env line excludes only the ROOT file: docker/.env (which holds CODEMAN_PASSWORD and any provider keys) rode COPY . . into the image until **/.env was added — verified in both directions with a real build context. ⚠️ A Compose LONG-form bind (type: bind) creates a missing host source directory ROOT-OWNED rather than refusing. Start-Codeman.sh pre-creates both CODEMAN_APPDATA_PATH and CODEMAN_CASES_PATH on the host before up, which is what keeps the daemon from ever having to materialise either as root in the first place; the container ALSO starts as root (cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID] against the base cap_drop: ALL; test/docker-entrypoint.test.ts pins that list) so docker/entrypoint.sh can correct a bind source that turns up root-owned anyway (a restored backup, a cleared directory, plain docker compose up run without the script) before dropping to PUID:PGID via setpriv — a directory owned by neither root nor PUID:PGID is never re-owned, since that ownership is not this container's to reassign; it is PROBED for writability as the runtime account (setpriv ... test -w, so ACLs, group-writable trees and CIFS/NFS mounts pass) and refused with a message naming path, owner and PUID:PGID if that fails. ⚠️ KILL is in that list for tini, not the entrypoint: init: true keeps tini as root while the server runs as PUID, and without CAP_KILL its SIGTERM forward fails and the server is SIGKILLed on every compose down/restart instead of flushing state. ⚠️ /opt/codeman-cli (the runtime-owned CLI prefix) is APPENDED to PATH, never prepended, and the entrypoint pins its own PATH to the system dirs: the root part of the start resolves setpriv by bare name, and a prefix ahead of /usr/bin let a planted setpriv run as uid 0 (measured). CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1 drops --memory-swap (and filters only that one kernel warning) for hosts without swap accounting; --memory still applies. ⚠️ The deployment ALSO self-updates in place (the repo bind mount at /opt/codeman + a restart-by-exiting supervisor) — see Self-update below and docs/docker-self-update.md before touching server.Dockerfile, the compose file or .env.example, since each is an input to the updater's environment gate. docs/docker-compose.md + docker/README.md (user guides)
CLI registry (src/config/cli-registry/): every run mode is a CliEntry — discovery (search dirs, version + identity probes), the launch argv template, env handling, the capabilities flags that replace per-CLI branching, and the overlays that back the remote/docker pane commands. No code outside stock.ts may branch on a CLI id; behaviour that genuinely differs is either a capability field or a NAMED PROFILE selected by one (profiles.ts), and test/cli-registry-no-id-branching.test.ts fails the build if an id check reappears — it matches ===, !==, case '<id>': and [...].includes(mode), because an earlier ===-only version let 36 negated branches survive the conversion (including a seven-mode Ralph chain whose own comment asked the next person to keep it in step with isExternalCliMode() by hand). ⚠️ Config contains no shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped --no-approve is not cosmetic), and values resolve through patterns NAMED in code, so a user clis.json cannot widen its own validation. ⚠️ external, hooks and altScreen are three INDEPENDENT capabilities on purpose; deriving one from another shipped the until=stop-hangs-on-shell bug. ⚠️ Two capability fields carry a REGEX from config (discovery.version.regex and capabilities.workDetect.workingLine) and both must compile through compileVersionRegex(), which caps length and refuses nested quantifiers; workingLine is the one that runs on the PTY hot path. ⚠️ param is TWO namespaces. launch.params keys, env.configSetenv[].fromParam and capabilities.privilegedParams[].param all name a LAUNCH PARAM; the legacy <Mode>Config wire field is a separate namespace, bridged only by launch.legacyConfigAliases. Getting privilegedParams[].param wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so schema.ts rejects an entry naming a param it never declared. Codex is the entry where the two names differ (bypassApprovals vs dangerouslyBypassApprovals) and therefore the one that catches a regression. ⚠️ Six fields are DECLARED-FOR-LATER and read by nothing (shortBadge, accent, capabilities.echo/wheelForward/keyboardAccessory/maxFrameBytes): all frontend behaviour, transcribed rather than measured, so re-measure before wiring one up; the list is pinned so it cannot quietly grow. Spawn commands are pinned as literal strings in test/cli-registry-spawn-golden.test.ts, remote/docker pane commands in test/location-overlay-commands.test.ts. ⚠️ That second golden no longer covers remote claude or remote omp: both now have their own arm in buildRemoteLaunchCommand (a --session-id || --resume pair, and --continue, so a respawn continues the same conversation) and never reach defaultRemoteCommandForMode, which is what that test asserts. Their real pins are toContain substrings in test/tmux-manager.test.ts and test/remote-shared-sessions.test.ts; changing either arm will NOT fail the golden. ⚠️ Anything reading the registry resolves it AT CALL TIME (sessionModeSchema(), allowedEnvPrefixes(), dependencyRegistry(), the resolvers' searchDirs thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. ~/.codeman/clis.json overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). → docs/cli-registry.md
External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP): isExternalCliMode() in session.ts gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. ⚠️ Work detection is no longer part of that gate: it is per-CLI capabilities.workDetect data (see the ❯ note above), so a CLI that declares its own glyph and working line gets the same screen-probed idle confirmation claude gets, and one that declares neither keeps the output-stabilization behaviour. All eight 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. ⚠️ Grok is codex-shaped on permissions but opencode-shaped on rendering: its bypass switch is alwaysApprove (--always-approve, grok's bypassPermissions mode — the Run button sends it true like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of isAltScreenStripMode(); the resolver version-probes grok --version like pi's (npm squatters exist for the name — GET /api/grok/status surfaces path + version), and grok lands on the 'buffer' echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the 'off' branch). Grok's own tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts; user guide docs/grok-integration.md. ⚠️ DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings. (1) The agent is a PROFILE, not the binary: dsh is a launcher over $DSH_HOME/profiles/<name> and DeepSeek ships only web/headless/base, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on isDeepSeekRunnable() (binary AND a pane-capable profile) while isDeepSeekAvailable() gates the "add a profile" affordance; a web/headless profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the DSH_PERMISSION_MODE env export, not a flag (read-only/workspace-write/danger-full-access) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with ?? as a boot-time default, so it stays soft; absent = workspace-write, which asks, hence the only-if-sent clamp branch, clamping to workspace-write (never read-only, which would break the workspace). ⚠️ That clamp needs a second half no other CLI needs, because the switch is an env var and DSH_* is an allowlisted envOverrides prefix: applyEnvOverrides() runs AFTER _configureCliEnv() in tmux-manager, so a non-granted owner sending DSH_PERMISSION_MODE on the SAME request would land last and hand back exactly the privilege the config clamp removed. clampEnvOverridesForOwner() (session-routes.ts) DROPS DSH_PERMISSION_MODE, DSH_HOME and DEEPSEEK_BASE_URL for a non-granted owner (the last because _configureCliEnv() forwards the SERVER's own DEEPSEEK_API_KEY into the pane, so a redirected base URL would send it to a foreign host) (dropping falls through to what _configureCliEnv() exports, which is the clamped value); DSH_HOME is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the only non-claude mode that passes hooksAvailableForMode(), and for it alone that predicate is a per-SESSION question rather than a per-mode one (deepSeekConfig.statusReporting: false disarms the bridge, so every call site passes sessionHookOptions(session); answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and deepseek-status-shim.ts makes Codeman that supervisor — real stop/blocked signals, real Approvals Inbox items, plus the agent_working event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (dsh --help must say DeepSeek Harness) because Debian ships an unrelated dsh (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ hooksAvailableForMode() is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare mode === 'claude' directly, because when deepseek earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in test/deepseek-mode.test.ts). ⚠️ It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane: deepseek-transcript.ts reads $DSH_HOME/sessions/<mangled-cwd>/<id>/session.jsonl.zstd and backs the last-response route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends one zstd FRAME per write and Node's zlib zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a plugin-sourced user/message (the runtime-context snapshot) that must not render as the user's words; and a failed turn/end is surfaced as Turn error: … rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own cwd plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: test/deepseek-mode.test.ts, test/deepseek-cli-resolver.test.ts, test/deepseek-transcript.test.ts; user guide docs/deepseek-integration.md. OMP (omp) needs no bypass flag (the CLI's own ~/.omp config governs trust/model routing, defaulting to tools.approvalMode: yolo), so its registry entry declares privilegedParams: [] and a launch spec that only ever passes --model/--resume/--continue — but the multi-user clamp is NOT a no-op for it: OMP_* is an allowlisted envOverrides prefix, and the two credential-resolution keys it admits, OMP_AUTH_BROKER_URL/OMP_AUTH_BROKER_TOKEN, are clamped in clampEnvOverridesForOwner() for a non-granted owner, the same shape as DEEPSEEK_BASE_URL. Separately, PI_* is already allowlisted (pi needs it) and omp reads several of its knobs too (PI_CONFIG_DIR, PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_SUBPROCESS_CMD, PI_SHELL_PREFIX) — a redirected PI_CONFIG_DIR moves the ~/.omp tree omp-session-resolver.ts/omp-transcript.ts hardcode, silently breaking pinning/history; this is a known gap shared with pi, not fixed here. → architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp
DeepSeek web UI (POST/GET/DELETE /api/deepseek/web, deepseek-web-server.ts): the Run menu's "DeepSeek web UI..." entry supervises ONE background dsh web child process, deliberately NOT a shell session. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: exactly one server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), restarted when the browser authority changes (--trusted-host fences dsh's /api against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), killed on shutdown (stopDeepSeekWeb() in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and failures returned to the caller, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own dsh web, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both POST and DELETE sit at the same privilege bar as the profile installer (canUsernameRunPrivilegedCommands) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs.
Custom Model Endpoint Profiles (opt-in, customModelEndpointsEnabled, SYNCED, default OFF; docs/custom-model-endpoints.md, design doc docs/custom-model-endpoints-plan.md; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (custom-model-hosts.ts, ~/.codeman/custom-model-hosts.json) discovered via GET <baseUrl>/v1/models; CustomModelHost.authStyle is 'bearer' (default, Authorization: Bearer) or 'api-key' (Azure's convention) — never both, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is capabilities.customModelInjection on the CLI registry (four kinds: env for claude/gemini/deepseek, configContentEnv reusing opencode's existing OPENCODE_CONFIG_CONTENT, configDir for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real ~/.codex/~/.pi/~/.omp/grok config — and unsupported for antigravity, which has no known mechanism), computed by the pure custom-model-injection.ts (mirrors session-cli-builder.ts's no-IO discipline). ⚠️ PI_CONFIG_DIR does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode ~/.pi/agent/models.json / ~/.omp/agent/models.yml with no dedicated override, so the real redirect for both is the child process's own HOME, and both need models as an ARRAY of {id} objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a config.toml [model.<name>] block redirected via GROK_HOME — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ Two launch paths, chosen by mechanism, not preference — see the second paragraph below for why: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by restarting the session's CLI process in place via Session.restartCli() — a de-restricted reattachRemote() reusing the same respawn-pane -k primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; Session.setCustomModel() undoes the PREVIOUS selection's env keys (and deletes its old configDir) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from _envOverrides is NOT enough on its own: tmux setenv persists at the tmux-session level and is inherited by respawn-pane (measured: setenv FOO bar survived two successive respawn-pane -k), so the retired keys are queued (_pendingEnvUnsets) and ride RespawnPaneOptions.unsetEnvKeys into applyEnvOverrides(), which setenv -us them BEFORE re-applying the live overrides. ⚠️ restartCli() kills a WORKING pane, so a CLI whose launch declares a fallback chain (claude) gets the live conversation id pinned as resumeSessionId for that one respawn: --session-id <id> refuses an id that already has a transcript (Session ID ... is already in use), and without the --resume <id> || --session-id <id> shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a model launch param (custom/<id> for pi/omp, grok's [model.codeman-custom] block name): that is the registry's customModelInjection.launchModel template, applied onto the respawn options through legacyConfigField by _withCustomModelLaunchModel(), never by id, and a model id the CLI's model token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their restartCli() reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report restarted:true and change nothing. The selection survives a Codeman restart as the disk-only __customModel (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (custom-model-hosts.json, the per-session config dir) is written 0600. ⚠️ Security: every env var this feature can redirect (ANTHROPIC_BASE_URL, GOOGLE_GEMINI_BASE_URL, CODEX_HOME, GROK_HOME, HOME for pi/omp, OPENCODE_CONFIG_CONTENT, etc.) is in that CLI's privilegedEnvKeys — several of these were reachable via the generic envOverrides field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. ANTHROPIC_* is deliberately NOT in claude's allowedPrefixes at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied envOverrides. Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC scripts/test-local-llm-harnesses.ts (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp PASS; codex config structure is correct, and codex only speaks the Responses API since Feb 2026 (wire_api = "responses") — re-verified live against a llama-swap deployment that DOES answer /v1/responses (an earlier test's harder failure against a different deployment does not reproduce everywhere): a plain, no-tool-call chat turn gets a real reply, but a real tool-call attempt came back as agent_message TEXT (the tool-call JSON printed as the answer) rather than an executable function_call item — confirmed via codex exec --json's raw event stream. Tool execution is what makes codex a coding agent, so it remains not usable for real work either way, just with a more precise failure mode than a flat protocol break; gemini fails with Invalid auth method selected (an undocumented GATEWAY AuthType gemini-cli selects once GOOGLE_GEMINI_BASE_URL is set — unresolved after real investigation); deepseek's originally-reported HTTP_404 is root-caused and fixed — its bundled @deepseek-ai/dsh-llm-deepseek module builds ${DEEPSEEK_BASE_URL}/chat/completions with no /v1 of its own (confirmed by installing the real package and reading its source), so a new appendV1Suffix flag on its registry entry (alone — claude/gemini must not get it) runs endpoint.baseUrl through withV1Suffix() before writing it, live-confirmed against llama-swap (.../chat/completions 404s, .../v1/chat/completions succeeds) though not yet re-run through an actual dsh binary, which isn't installable in this environment; antigravity has no known mechanism at all. See the confidence table in docs/custom-model-endpoints-plan.md for the full detail on each. ⚠️ The Run-menu picker generates entries from window.__codemanCustomModelClis (server.ts, injected at page render from enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported'), JSON-escaped against a literal </script> via the exported escapeScriptJson() since label is a user-clis.json-settable string unlike the neighbouring booleans-only __codemanCliAvailable), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through isCliAvailable() like the stock entries. Clicking one calls selectCustomModelEntry(mode, endpointId) (session-ui.js), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open #customModelPickModal to ask, with defaultModelId marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). Either way the actual launch (runCustomModelEntry) routes through run() itself via a temporary _runMode swap — never setRunMode(), which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same _runInFlight lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then GETs /api/sessions/:id/wait?until=idle&timeout=20000 on that session BEFORE applying — measured live, a freshly launched CLI reports itself busy for its own startup (boot spinner, workspace-trust check) well before the apply call would otherwise reach it, and the apply route's isBusy() guard correctly can't tell that apart from a real turn in progress, so every fresh launch failed with SESSION_BUSY until this wait was added. A timeout there is a normal 200 per the wait endpoint's own contract, never an error, so a session still busy after 20s just reaches the apply call anyway and gets that route's own honest error. It then calls POST /api/sessions/:id/custom-model on the session run() produced, guarded by snapshotting activeSessionId before the call and requiring it to have actually changed after — every run*() handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open. ⚠️ The apply call reads the response body itself (_api()) rather than _apiJson(), which unwraps success but silently discards a failure body — losing the one thing (error) that distinguishes "still busy", "remote/Docker session" and everything else the route can report (⚠️ neither route validates modelId against the endpoint's discovered list, deliberately: discovery can be up to 5 minutes stale, so a 400 there would refuse a launch that works — a typo'd id fails on the CLI's own first request instead); the resulting toast is type: 'error' with an explicit duration: 0 (no auto-dismiss, an explicit close button) at that one call site — not a blanket sticky-error default, which stacked unbounded on .toast-container with no cap or eviction — precisely so a message worth diagnosing survives long enough to be read instead of vanishing on the usual 3s timer. Entries are hidden for a remote/docker active case (the apply route refuses both) and for an endpoint with no discovered models at all (nothing to launch with). ⚠️ Every saved endpoint's models also re-discover themselves automatically, a this.cleanup.setInterval in server.ts (CUSTOM_MODEL_REDISCOVER_INTERVAL_MS, 5 minutes, off under testMode like the Codex plan-usage poll beside it) calling the exported refreshAllCustomModelHosts() (custom-model-routes.ts) — one endpoint unreachable on a cycle never blocks the others, and a read-modify-write PER HOST (re-reading the store before each splice, keyed by id) means an admin's concurrent edit or delete wins over a sweep that started before it, never the reverse.
Everything below landed after the initial backend + picker cut, each confirmed live against a real llama-swap deployment. ⚠️ llama-swap runs one model at a time, and switching can disrupt ANOTHER live session — before applying, both apply routes call llama-swap's own GET /running (feature-detected via getLlamaSwapStatus(), custom-model-routes.ts; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is simply never checked). If a different model is loaded and ready AND another live session's own selection is using it, the apply returns {requiresConfirmation, currentlyLoadedModel, affectedSessions} instead of switching silently; retrying with confirmedSwap: true skips the check, and switching with nothing else affected proceeds immediately. ⚠️ The swap question and the context-floor question below have SEPARATE flags (confirmedSwap, confirmedContext), because the context check runs first and while both shared one confirmed a user who clicked past a too-small context silently consented to evicting another session's model too; the legacy confirmed still means both, since it shipped in the HTTP-API-only cut. llama-swap also has no dedicated "switch model" endpoint — the only thing that actually starts a swap is a real inference request naming the model (confirmed live: applying a selection alone never reached llama-swap's own logs, since nothing had asked it to load anything) — so both routes also fire triggerLlamaSwapLoad(), a fire-and-forget POST <baseUrl>/v1/chat/completions with max_tokens: 1, whenever the target model isn't already loaded and ready. ⚠️ That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. detectCustomModelSwapDisplacements() (custom-model-routes.ts) is a separate periodic sweep (server.ts, CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS = 20s) that compares each live custom-model session's own modelId against what /running actually reports loaded, broadcasting a custom-model:swapped-out SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe Set cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. ⚠️ Context length is read from the REAL launch command, never /props — /props?model='s default_generation_settings.n_ctx was confirmed live to report a --fit-ctx-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of /running's own cmd field instead (parseCtxFromCmd: --fit-ctx <N> first, then plain llama.cpp -c/--ctx-size), falling back to /props only when cmd states no recognizable flag at all. ⚠️ Claude alone gets a context-window FLOOR check, on top of the ceiling contextLengthVar already fixes — exceedsSafeContextFloor() (gated on the registry declaring contextLengthVar, so a no-op for every other CLI by construction) compares a model's discovered context against CLAUDE_MIN_SAFE_CONTEXT_TOKENS (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what CLAUDE_CODE_MAX_CONTEXT_TOKENS says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns {requiresContextWarning, modelId, contextLength, minSafeContextTokens} instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger -c/--ctx-size in llama-swap's config instead of relying on --fit-ctx auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ A fresh, isolated CLAUDE_CONFIG_DIR looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. skipFirstRunPrompts (claude's entry only, requires apiKeyTrustFile since it reuses the same file) pre-seeds that same "already been through this" state: hasCompletedOnboarding and this session's own projects[workingDir].hasTrustDialogAccepted merge into the same .claude.json the API-key trust file already writes to, and skipDangerousModePermissionPrompt merges into settings.json (a different file, same corrupt-tolerant merge). ⚠️ The loading banner shows the REAL backend log line, not a guess, and has no countdown or auto-timeout at all. getLatestLlamaSwapLogLine() holds one GET /api/events SSE connection open per endpoint (confirmed live to stay open indefinitely — read past 220KB over 8s with no done; idle-closed after 30s via pruneIdleLlamaSwapLogTails, same 20s sweep as the swap-displacement check above), parsing logData frames and keeping only source: "upstream" (the real llama-server process's own stdout) lines, never source: "proxy" (llama-swap's own request-access log). ⚠️ GET /logs — the endpoint this feature's own first cut targeted, since the name suggested it — was confirmed live to carry ONLY the proxy log and never a single backend line, even seconds after a real, verified model swap; caught and corrected by a live check before merge, not after. The banner itself dropped its size-scaled expected-time estimate and matching auto-timeout (a guess dressed up as a fact that could kill a genuinely slow load partway through on slower hardware) for a generic hardware/model-size disclaimer plus a user-driven Cancel button (_showCenterStatus's onCancel option, a real button distinct from the plain "×" close glyph an 'error'-type banner gets) that ends the wait and closes the session on the user's own call rather than a guessed deadline.
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.
Auto-named sessions (autoNameSessions, SYNCED, default OFF; #376): a placeholder tab (w3-myapp) takes its first real prompt as a title, in the <prefix>: <title> form (w3-myapp: fix the login redirect) that parseSessionPrefix() (app.js, #232) already renders as the title alone with the prefix in the tooltip and that _nextCaseSessionStartNumber() still counts, so the case identity and the w<n> counter survive. Ownership is the tri-state SessionState.nameSource: placeholder (Codeman's own w<n>-<case> or no name, inferred by isGeneratedSessionName() when a persisted state predates the field), auto (titled once), manual (the name setter, i.e. PUT /api/sessions/:id/name, which auto-naming never touches again). ⚠️ First prompt means the FIRST: applyAutoName() flips a placeholder to auto whether or not the string changed, so a later "1" cannot rename the tab; a prompt that yields no title (/clear, a ! shell escape) leaves the session eligible for the next one. ⚠️ Only user-originated input counts (SessionWriteOptions.fromUser, set by the browser WS path and POST /api/sessions/:id/input ONLY, so a forgotten flag on a new path fails toward not naming): Ralph kick-starts, respawn /clears, cron launches, approval answers and the trust-dialog keys write through the same write()/writeViaMux() and used to name every Ralph tab "Read @ralph_prompt.md…". A startMode: 'shell' CLI never feeds the tracker (a capability, not an id check; a shell tab was renamed after every ls), and the send-key route's Shift+Enter line feed bypasses the session entirely, so it calls trackUserInput() or the two lines join with no separator. ⚠️ The tracker (session-auto-name.ts, pure) sits on the raw keystroke stream, so every key has an explicit rule: a bare Esc is resolved at the END of the chunk it arrives in (it used to stay in escape mode and eat the next prompt's first character, or a whole CJK prompt); SGR mouse reports, Tab, cursor keys and Shift+Tab leave the draft alone (a wheel tick mid-word used to drop the first half); Up/Down and Ctrl+P/N/R TAINT the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines are newlines IN the composer, never Enter. The title is the first sentence past a minimum length ("e.g. fix this now" is not "e.g."), capped at 72 code points, and the composed name honours MAX_SESSION_NAME_LENGTH. The listener (session-listener-wiring.ts) checks eligibility BEFORE reading the setting, so an already-named session costs no settings read per prompt. Opt-in because the prompt lands in the tab name, mux-sessions.json, every session:updated and /api/search (Read My Mind keeps prompts 0600 for the same reason). Tests: test/session-auto-name.test.ts, test/session-listener-wiring.test.ts, test/routes/session-name-routes.test.ts. → architecture-invariants#auto-named-sessions-first-prompt--tab-title
Maintainer bot (external): the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at scripts/pr-bot/. It moved OUT of this repository on 2026-09-14, to ~/codeman-cases/prbot/ (its own private git repo, systemd unit codeman-pr-bot, guide + agent rules in its own README.md and CLAUDE.md). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named prbot-<n> / dscbot-<n> on the local Codeman and holds clones under ~/.codeman/pr-bot/, so those session names and that data dir are taken; it also fetches PR heads into refs/pr-bot/* of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
Unified session list: GET /api/sessions/unified merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in src/services/unified-session-service.ts). ⚠️ Transcript history is THREE stores, not one, because each CLI keeps its conversations in its own: Claude's ~/.claude/projects, omp's ~/.omp/agent/sessions and codex's ~/.codex/sessions (#386). Rows fold into their owning session via the claudeSessionId → Codeman id alias map, so resumed and /clear-respawned sessions do not appear twice; that field is named for Claude and carries whatever id the CLI names its conversation with, which for every non-Claude row diverges from the Codeman id by construction. ⚠️ resumeId is set by a SCANNER row only, never by a live session, and that is what makes it safe to resume on: a row carrying one is a conversation already on disk, so resumeHistorySession() sends codexConfig.resumeSessionId and a row without one is a genuinely fresh session. Every surface that re-projects these rows has to carry the field through, the phone overview included, or a tap on that surface silently starts a second conversation. 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
Owner tab layouts (COD-359, tab-layout*.ts + GET/PUT /api/tab-layout): named tab GROUPS over the flat tab strip, scoped per owner (SINGLE_USER_LAYOUT_OWNER = @single when multi-user is off), persisted under the tabLayouts key in state.json. A layout is {version, groups[], ungrouped[], updatedAt} whose refs point at either a session or a saved webview (TabRefKind), capped at 32 groups / 512 refs. ⚠️ BACKEND ONLY as of 1.24.1: nothing in src/web/public/ calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. ⚠️ TabLayoutService is the single mutation boundary and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. ⚠️ The layout does not replace PUT /api/session-order, it PROJECTS onto it: tab-layout-legacy-order.ts is the pure translation both ways (putLegacyOrder() recomposes a global order from the owner's groups), so changing one side without the other silently desyncs the tab strip from the stored layout. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (markRestorationComplete / markRestorationFailed / markRestorationSkipped, plus assertDeletionReady()): pruning refs against a session list that failed to load would delete live tabs, so a failed restore must leave the layout untouched. PUT takes exactly {baseVersion, layout} (any other key shape is a validation error), answers a stale baseVersion with the current layout rather than clobbering, and is capped at 128 KiB. Broadcasts tab:layoutChanged, owner-routed via deriveTabLayoutSseHint.
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, prompt_submitted (UserPromptSubmit, #367: a Claude pane reports its live conversation id first-hand). 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.
Reboot restore (#411/#442, src/reboot-restore.ts pure + web/reboot-restore-registry.ts + routes/reboot-restore-routes.ts + reboot-restore-ui.js): a host reboot takes the tmux server with it, so every pane dies and the board comes up empty with no explanation. At boot Codeman works out which sessions that reboot destroyed, holds the plan IN MEMORY (no new state file, and a server restart simply drops the offer), and the banner asks. ⚠️ The heuristic decides whether to ASK, never whether to act: two signals have to agree (the socket holds no panes at all while state still lists sessions, AND the host booted after the newest persisted activity), and a wrong yes costs one dismissable line rather than N CLI processes nobody asked for. ⚠️ Rebuilding is TAKE-then-build: entries leave the plan synchronously before the first await and the route is single-flighted per owner, so a double-click or a second device cannot put two panes on one conversation. Anything that never became a pane goes BACK on offer, with one deliberate exception, already-live, which unlike a missing workspace or a withdrawn grant cannot stop being true. ⚠️ Three things are re-checked at click time rather than trusted from boot (the owner's privilege grant, the workspace still being on disk, and the conversation not already being live), and the already-live sets are read FRESH per iteration rather than snapshotted: the loop awaits a real startInteractive() per entry, so a snapshot taken before it is tens of seconds stale by the tenth entry and would miss a conversation the user resumed by hand in that window. The confinement re-check is keyed on the entry's OWNER, never the caller, or an admin spending another user's entry is waved through by isWorkingDirAllowed. ⚠️ A rebuilt session comes back attached, idle and disarmed: the pane is NEW, so terminal scrollback is gone (the banner says so) while the conversation continues, respawn controllers and Ralph loops are never re-armed, and reapplyPersistedSessionState(..., { rearmAutoResumeSchedule: false }) keeps auto-resume ENABLED but drops the pre-reboot autoResumeAt stamp, or one click has every restored session type continue into itself a minute later, unattended. That option exists only for this path; a Codeman restart still re-arms, because the limit footer will not reprint on its own. ⚠️ The rebuild passes nameSource through, or the constructor re-infers it from the name and a hand-renamed session shaped like w<n>-<case> comes back as placeholder for auto-naming to overwrite. ⚠️ Claude-mode only (others carry their conversation id in their own config object), and remote/docker sessions are never offered (remote-or-docker), because both need another host or container to be up. ⚠️ A failed rebuild is undone with discardPartiallyBuiltSession(), deliberately NOT cleanupSession(): the delete path would count the session's tokens into the lifetime totals, demote a pinned record to stopped (which this pass reads as an intentional kill, making the session permanently unrestorable) and recursively remove the WORKSPACE's .claude-images. ⚠️ os.uptime() reports the HOST's uptime, which a container shares, and that cuts both ways: after a genuine host reboot a containerized Codeman does see a short uptime and the banner works, but a container-only restart is invisible to it, which is the case where this would help most. Tests: test/reboot-restore.test.ts, test/routes/reboot-restore-routes.test.ts, test/routes/reboot-restore-rebuild-failure.test.ts, test/discard-partially-built-session.test.ts.
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 ALONE is restricted to idle items; a permission/question item gets the pane-VERIFIED variant on that same signal (resolveIfDialogGone() → verifyStillAnswerable()), so the heuristic only decides when to LOOK and the screen decides the outcome. That is what clears a dialog answered in the terminal mid-turn; the other definitive signals are stop, elicitation_complete/elicitation_response, exit/delete, answer, supersede and the 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). ⚠️ applyCapture() is therefore ADD-ONLY for options: a re-capture that parses nothing must never erase a parse an earlier one found. Claude Code delays the Notification hook behind the dialog (measured 6s, documented ~30s), so the 600ms re-capture routinely lands on a frame the user has ALREADY answered; clearing the field there made the item permanently unsweepable, because verifyStillAnswerable() reads a MISSING options as "we never could read this dialog" and keeps such items answerable by design. The red "needs you" then survived every sweep AND every page reload, went away only on stop (2026-08-20: a confirmed question left a tab flowing red for ~8 minutes while the turn ran on), and the stale card still accepted an answer, typing a bare 1 into a composer with no dialog under it. Pinned by test/approval-inbox.test.ts. ⚠️ A frame that parses no options is CONCLUSIVE in exactly two cases, and the second one closes the late-hook hole: the item once parsed options (they cannot vanish while the dialog is up), or the frame shows Claude actively running a turn. A modal dialog BLOCKS the turn, so the two cannot coexist — measured on v2.1.237, a live-dialog frame carries neither the … (13s timer NOR the esc to interrupt footer, which the dialog replaces with Enter to select · ↑/↓ to navigate · Esc to cancel. Anything else stays answerable, so an unreadable capture still keeps the alert. That second signal is reached by a delayed staleness pass (STALE_CHECK_DELAY_MS, 3s) scheduled alongside the re-capture, because a prompt answered BEFORE the hook lands creates an item whose FIRST capture already has no dialog in it: nothing ever parsed, stop may have fired already, and the alert then outlived reloads until the 12h TTL. ⚠️ That pass must stay comfortably LATER than RECAPTURE_DELAY_MS, whose whole reason for existing is that the hook can beat Ink to the screen — resolving inside the paint window would clear the alert for a dialog that was about to appear. 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 and automatic drop recovery always use a bounded 1 MiB ?tail= window. Shell loads the rest only when Load full history is pressed; ordinary 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). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ A full=1 capture ENDS with a cursor move back to the pane's own caret position, counted UP from the last replayed row — without it the caret stays where the last character landed, which for an agent CLI is the status line, and every cursor-relative update the CLI sends afterwards is measured from the wrong row. The move is relative, not CUP: absolute row addressing is only right while the browser's rows equal the pane's, and resizeWindow does not wait for tmux, so a capture can be taken before a requested resize applies. That makes row alignment load-bearing on this path: no transform that can DELETE A LINE may run over the capture, so it keeps its trailing blank rows and skips redraw-bloat stripping, the banner trim and the leading-whitespace strip. ⚠️ Those three skips key on whether a capture actually CAME BACK (isFullCapture), never on ?full=1 alone — the fallback to the byte history is a stream of successive frames that must still be stripped, and a session with no mux takes it on every load. A capture holding nothing visible returns '' so the byte history survives instead of a blank screen replacing it. ⚠️ A full 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. ⚠️ A visible capture now REPORTS the geometry it was taken at (captureCols/captureRows, #435), because a frame built for a pane taller or wider than the browser is damaged two ways at once (overflow rows clamp onto the last line; a narrower browser wraps every painted row) and nothing in the response used to say so. Both fields are ABSENT when no frame was positioned, so every consumer tests Number.isFinite, never truthiness: a display-message cursor query that fails makes capturePaneBuffer return the raw capture while the route still labels it mux-visible. The comparison runs on mux-visible ONLY, the replay is capped at one attempt, and a pane that cannot be sized to fit latches in _geometryRetryUseless so it is diagnosed once per session rather than on every tab switch. → architecture-invariants#full-scrollback-replay
Split-pane sessions (showSplitButton, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own SplitTerminalPane (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → architecture-invariants#split-pane-sessions
Terminal touch gestures: link taps and text selection: on 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.
Auto Copy (copy-on-select) (autoCopySelection, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in onSelectionChange (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS _autoCopyPending, and a document-level mouseup listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates navigator.clipboard.writeText on it, and the plain-HTTP execCommand fallback must run in the gesture's own task); a timer or a wait for onSelectionChange loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from _endTouchSelectionGesture()/_selectTouchSelectionLine(): that path preventDefault()s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike copyTerminalSelection() it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the execCommand fallback. Guards are pure in decideAutoCopy() (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: test/terminal-auto-copy.test.ts.
Ctrl+V paste trap (image-input.js): Ctrl+V routes through _handleImagePaste(), which appends a hidden contenteditable trap, focuses it, and reads the clipboard out of the paste event that lands there. Images upload and their saved paths are typed into the session; text goes through terminal.paste() so bracketed-paste markers survive. ⚠️ The trap must consume exactly ONE paste event. Two routes deliver one for a single keypress and Firefox fires both: document.execCommand('paste') dispatches a trusted event and still returns false, because the trap cancels it, while Chromium refuses that command and dispatches nothing; separately, the keydown's own default action delivers a paste to the now-focused trap, because returning false from the custom key handler never cancels the DOM event (see smart copy above). Measured on a live install: Firefox two events per keypress, Chromium and WebKit one. Handling both wrote the clipboard to the PTY twice, and right-click → Paste stayed correct because it carries no keydown. ⚠️ Removing the execCommand('paste') call would also end the doubling, and all three engines still deliver one event without it, but it stays for the mobile engines a desktop measurement cannot reach: where a browser aims the key's default action at the element focused when the keydown began, the command is the only route into the trap, and the trap is the only place image blobs are read. The one-shot flag lives on the trap rather than on a browser check, so any count produces one insert. Tests: test/image-paste-trap.test.ts. → architecture-invariants#terminal-paste-ctrlv
Terminal scrollback strip + wheel/touch forwarding (#205): codex/claude/gemini get the FULL strip (alt-screen, 3J, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only — 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). ⚠️ A click is hand-reported to the CLI only while the CLI actually has mouse tracking on. The full strip removes the mouse DECSETs, so xterm's mouseTrackingMode is permanently none there and the browser hand-encodes SGR reports (_sendSyntheticSgrTap); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text ([<0;88;20M), garbling the next typed line. _recordStrippedMouseMode() (session.ts) records what the strip removes, toState() publishes cliMouseTracking, and _shouldReportMouseToCli() gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. _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, docker-compose, 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. ⚠️ The Compose deployment is the one supervisor that does NOT outlive the restart: there the restart IS the container exiting (restart: unless-stopped relaunches it), which kills the script too — safe only because the terminal restarting marker is written BEFORE the kill, so nothing may be appended after it. Two config facts make it work at all and both are load-bearing: the repo is a HOST BIND MOUNT over /opt/codeman (a pull into the baked image copy would land in the writable layer and be silently discarded by the next up), and the runtime image keeps devDependencies + a build toolchain (npm run build is tsc+esbuild, and node-pty has no Linux prebuild), which is why npm prune --omit=dev is gone and the updater passes --include=dev against NODE_ENV=production. ⚠️ An in-place container update applies CODE ONLY — a restart reuses the existing image and config — so evaluateEnvironmentGate() REFUSES a release that changes server.Dockerfile/docker-compose.yaml (sha256 vs the baseline Start-Codeman.sh writes to docker-env-applied.json on every start) or adds .env.example keys the user's .env lacks, and refuses when the restart policy would not bring the container back. That third check exists because Compose resolves an unset ${VAR} to the EMPTY STRING and starts anyway, so a new required setting otherwise arrives as a silently blank env var. Every unknown fails OPEN in the gate (no baseline, unreadable .env, no socket): failing closed would permanently block containers created before the fingerprint file existed. ⚠️ The KILL does not: the server exits only when --restart-by-exit 1 was passed, i.e. the Compose file declared CODEMAN_RESTART_BY_EXIT=1 (set ONLY there, since that file is what sets restart: unless-stopped; the image ENV deliberately does not) or the daemon reported an auto-restart policy; otherwise the build lands as completed-needs-manual-restart, because exiting blind takes a docker run container with no restart policy down with no UI left to recover it. The gate is re-evaluated on POST /api/system/update, so hiding the button is UX, not the control. ⚠️ The four global agent CLIs in server.Dockerfile are PINNED on purpose — unpinned, a user's CLI versions are a function of when their image was built rather than of any commit, which is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already catches. test/docker-compose-env-parity.test.ts is the merge-side guard (every compose ${VAR} ↔ an .env.example entry). → docs/docker-self-update.md, architecture-invariants#self-update
Reverse-proxy base path (--base-url / CODEMAN_BASE_URL, default /; src/config/base-path.ts is the pure single-source, normalized to '' for root or /foo): lets Codeman be mounted under a sub-path behind a proxy that forwards the prefix unchanged (does NOT strip it). Deliberately few choke points, mirrored ingress/egress: (server ingress) stripBasePath() runs inside Fastify's rewriteUrl so ALL routes stay declared prefix-agnostic (/api/..., /ws/...) — and a request arriving WITHOUT the prefix (hooks, health checks, docker bridge, all hitting the raw port) is left untouched, so the server answers at both; (server egress) one onSend hook prepends the base to every root-absolute Location header, covering all redirects; (HTML) renderIndexHtml rewrites the shipped <base href="/"> to the mount and injects window.__CODEMAN_BASE__ — the template's asset refs are all RELATIVE so <base> handles them for free; (frontend runtime URLs) root-absolute URLs ignore <base>, so CodemanBase.url() (constants.js) is the route builder, applied transparently by a fetch wrapper and explicitly at the few EventSource/WebSocket/window.open/<img|iframe|a>-src sites; (sw.js/manifest) the worker derives its base from self.location, the manifest uses relative start_url/scope; (web-tab proxy) proxyPrefixFor(cap, basePath) is the single base-aware root that cascades to the injected <base>, root-absolute HTML rewrites, the runtimeUrlShim, Set-Cookie Path and Location rebasing — while the INGRESS parsers (capabilityFromProxyPath, resolveUpstreamUrl) stay base-agnostic because rewriteUrl strips the prefix before routing, and capabilityFromReferer(referer, basePath) strips it from the browser-supplied Referer. ⚠️ --base-url rides the daemon relaunch via buildWebArgs and the service unit via resolveServicePlan. Pure helpers unit-tested in test/base-path.test.ts + test/webview-proxy.test.ts; HTML injection in test/render-index-html.test.ts.
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
Files panel search (COD-236, the q param on GET /api/sessions/:id/files): compileFileQuery() (utils/file-query.ts, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree. ⚠️ A query turns that endpoint into a FLAT match list rather than a nested tree, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to null, which is what keeps the default tree response byte-identical when no search is requested. ⚠️ Globs are never compiled into a RegExp: *a*a*a… translated to ^.*a.*a.*a…$ is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason search-service.ts is regex-free). globMatch() is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (MAX_QUERY_LENGTH, 256) also compiles to null rather than running. A query containing / matches the relative path, otherwise the bare entry name; globs match anchored and case-insensitively (* spans any run, slashes included, ? exactly one character), everything else is a plain case-insensitive substring.
Raw file bodies are streamed and range-aware: file-raw, the attachments /raw route and GET /api/download 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). ⚠️ The size cap on all three is a sanity bound, not memory protection (MAX_FILE_DOWNLOAD_BYTES in config/buffer-limits.ts, default 2GB, env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited): the bodies stream, so size costs a read stream and not RSS (measured: a 600MB download moved peak RSS by ~37MB). Its predecessor was a hardcoded 50MB whose comment still said "prevent memory exhaustion" long after the readFile() it described was replaced by sendFileBody(), so all it did was refuse legitimate downloads of build artifacts, videos and archives. /api/download was the last route that really did buffer the whole file, and now shares sendFileBody() with the other two. ⚠️ 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 SessionMode of its own (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. ⚠️ Egress guard: link-local and cloud-metadata targets (169.254.0.0/16, fe80::/10, fd00:ec2::254, the Azure/Alibaba fixed addresses, metadata.google.internal) are refused at save time AND on the RESOLVED address at connect time (webview-egress-policy.ts, pure, plus webview-egress.ts: a lookup hook on the undici Agent behind webviewFetch() and on the ws client). An IP literal never reaches a lookup hook (net.connect skips DNS for it), so the synchronous hostname check at each connect site is NOT redundant. Loopback and RFC1918 stay allowed on purpose: a localhost Grafana is the feature. The proxy uses the undici PACKAGE's own fetch + Agent, never Node's global fetch with a foreign dispatcher (Node bundles its own copy; a protocol mismatch fails silently). ⚠️ A loopback link in agent output opens as a proxied web tab (openLinkThroughWebTabIfLoopback in webview-tabs.js, called from the terminal link provider and the response viewer): a phone cannot reach the box's localhost:5173, so the tap routes through Codeman's origin instead, reusing a saved same-origin dashboard or saving one under host:port. Two rules that look cosmetic and are not. *.localhost is deliberately NOT in the auto-route set even though it IS loopback to a browser: the link source is agent-written terminal output and prompt-injectable, every other member of that set is an address literal that can only mean this box, and a *.localhost DNS name is not one (a resolver with a search domain retries evil.localhost as evil.localhost.<search domain>, which an attacker can control, turning agent output plus one tap into a persisted server-side fetch of an agent-chosen origin). A user who really runs api.localhost saves it by hand, which is an explicit action. The PAGE-side test is deliberately broader (isOnBoxHostname), since a false positive there only declines to proxy. And this.webviews being set does NOT mean it is loaded: initWebviews() assigns a truthy EMPTY map synchronously and only then awaits the list, so a tap during page load must join the in-flight refresh (_webviewsRefresh/_webviewsLoaded) or it finds nothing to reuse and POSTs a duplicate record for an origin that already exists. Capabilities are revoked on logout / admin logout / user deletion (revokeOwner, which had NO caller for two releases while its docstring said otherwise), and proxied responses carry Referrer-Policy: same-origin so a dashboard cannot hand the capability-bearing URL to a third party. → architecture-invariants#web-tabs, docs/web-tabs.md
Multi-user mode (opt-in --multiuser / CODEMAN_MULTIUSER=1, OFF by default): named users with scrypt-hashed passwords in ~/.codeman/users.json. Gated everywhere by isMultiUserMode(); when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ Not a security boundary at the agent layer: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through Session.owner and is enforced in findSessionOrFail, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → architecture-invariants#multi-user-mode, docs/multi-user-plan.md
Away digest: GET /api/away-digest aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in web/away-digest.ts. ⚠️ Returns {success:true,digest}, a legacy raw-ish shape consistent with the other raw GET handlers in system-routes.ts; frontend and tests read .digest. → architecture-invariants#away-digest
Ralph todo-config: per-session maxTodos (FIFO-eviction cap, default 500 = MAX_TODOS_PER_SESSION) + todoExpirationMinutes (auto-expiry, default 60) set via POST /api/sessions/:id/ralph-config (RalphConfigSchema, both .int().positive()). Stored on the tracker (setMaxTodos/setTodoExpirationMinutes) and persisted/read-back via RalphTrackerState (surfaced in the loopState getter → toState() + SSE broadcast → modal populateRalphForm), mirroring how maxIterations round-trips. Claude-only (skipped by isExternalCliMode).
Port interfaces: Routes declare dependencies via port interfaces (src/web/ports/). Routes use intersection types (e.g., SessionPort & EventPort).
Frontend
Frontend JS modules have @fileoverview with @dependency/@loadorder tags. Load order: constants.js(1) → i18n.js(1.5) → mobile-handlers.js(2) → voice-input.js(3) → notification-manager.js(4) → keyboard-accessory.js(5) → input-cjk.js(5.5) → terminal-keycode229-recovery.js(5.55) → sanitize-html.js(5.6) → app.js(6) → tab-rail-resize.js(6.5) → terminal-ui.js(7) → terminal-split.js(7.5) → respawn-ui.js(8) → ralph-panel.js(9) → orchestrator-panel.js(9.5) → cron-ui.js(9.7) → settings-ui.js(10) → panels-ui.js(11) → readmymind-ui.js(11.3) → ultracode-panel.js(11.5) → approvals-ui.js(11.6) → reboot-restore-ui.js(11.65) → admin-ui.js(11.7) → session-ui.js(12) → host-wake-ui.js(12.2) → webview-tabs.js(12.5) → mobile-overview.js(12.55) → home-sessions.js(12.56) → entrance-animations.js(12.6) → ralph-wizard.js(13) → api-client.js(14) → subagent-windows.js(15) → ultracode-windows.js(15.5) → session-lineage.js(15.6) → image-input.js(16). i18n.js translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; input-cjk.js handles CJK IME composition via an always-visible textarea below the terminal (window.cjkActive blocks xterm's onData). terminal-keycode229-recovery.js forwards a committed input event that xterm's _inputEvent guard drops (Chrome-on-Android soft keyboards send composed: true after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ That decision is settled at the NEXT keydown as well as on its own zero-delay timer (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the \r. On the timer alone that character is not merely late, it is LOST: xterm emits the \r first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, hell\r where the user typed hello). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by test/terminal-keycode229-recovery.browser.test.ts, which the CI gate does NOT run.
Entrance animations (entrance-animations.js, all OFF by default): opt-in animations for 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; test/entrance-animations.test.ts pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ blur is the ONE style that puts a filter on the terminal container, against the standing rule, because every alternative was measured against a live xterm and does not work: a backdrop-filter veil on ::before blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes blur(15.3px) and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, will-change still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for fade. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The blur connection line animates filter too, so both kinds of line hold their glow in --line-glow and both of its keyframes say blur(N) var(--line-glow): the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits opacity so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what line-enter-fade's hardcoded 0.9 gets wrong. ⚠️ 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 phone block (under 600px), 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 hosts (#sessionTabsHost in the header, #sessionSidebarList in the aside, #tabRail for the vertical rail below), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged. Exactly TWO functions reparent it and they must run in this order: applySessionListLayout() first (sidebar wins), then applyTabOrientation() (settings-ui.js), which moves the tabs into #tabRail only when the sidebar does not own them. ⚠️ 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. ⚠️ The vertical tab rail (tabOrientation/tabRailWidth/sessionSidebarFontSize, all per-device display keys that ARE in the schema, like sessionListLayout) is a SECOND vertical list next to the sidebar: the orientation setting is silently ignored while the sidebar layout is chosen, desktop/tablet only (resolveTabOrientation forces horizontal on mobile), resizable via tab-rail-resize.js (which owns terminal refits during the drag). ⚠️ Detailed rows are a property of a vertical LIST, not of one surface (sessionListLayout: 'sidebar-rich' for the sidebar, tabRailDetail: 'rich'|'simple' for the rail, rail default rich): both draw the home screen's per-session line (created 3d ago · working 12m) plus a status pill, from the SAME row model (_sidebarRichRow/_sidebarRichMetaHTML in app.js, classified by _mobileOverviewState/_mobileOverviewSince), and the render paths ask ONE gate, isRichTabRows() (= isSessionSidebarRich() || isTabRailRich()). ⚠️ Detail rides on its own attribute (data-sidebar-detail / data-tab-rail-detail) so every existing [data-session-list="sidebar"] / [data-tab-orientation='vertical'] rule keeps matching both variants untouched; a flip of detail ALONE still needs a full render (the stamps line is emitted by the row template, not toggled by CSS) and must re-run applyTabWrapSettings(), which owns the folder line and is now rail-aware. ⚠️ The rich CSS rules carry a rail twin as a COMMA-GROUPED selector, never :is() (an :is() list takes its most specific argument, which would lift the sidebar arm from (0,3,1) to the rail's (0,5,1)). ⚠️ Width is the whole reason there are thresholds: the rich sidebar is 300px (--sidebar-width-rich) and a rail that has never been sized defaults to 320 (RICH_DEFAULT_WIDTH, the existing Wide preset) instead of 256, because at 256 the stamps line ellipsizes mid-word; a user-narrowed rail drops the created stamp below 288 (tab-rail-tight, CSS only) and drops rich rows entirely below 240 (tab-rail-compact, which re-renders). A stored width is never overridden. ⚠️ Only detailed rows carry stamps that go stale with no event behind them, so _startSidebarRichClock() (20s, rewrites text in place — a re-render would restart every row's animation) must be armed and disarmed by BOTH applySessionListLayout() and applyTabOrientation(). ⚠️ Axis decisions must use _isVerticalTabList() (sidebar OR rail), never isSessionSidebarActive() alone: the rail leaves data-session-list at header, and the sidebar-only predicate shipped four rail bugs at once (drag insertion side read from clientX, active tab never scrolled into view, floating windows anchored below tabs instead of beside them, connector redraws skipped on rail scroll). The pre-paint script stamps data-tab-orientation (+ --tab-rail-width) like it stamps the sidebar keys, or vertical mode flashes through the header strip; the name font size defaults to 12px, the sidebar's historical size, so untouched installs are never restyled. ⚠️ 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. ⚠️ The detailed RAIL additionally wears the home rail's CARD, and the detailed SIDEBAR deliberately does not: the rail is an occasional, resizable list you scan, the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall — so the card rules are RAIL-SCOPED and were NOT added to the comma-grouped selectors above, which is the one-line change that would silently restyle the sidebar. Card state accents reuse home-sessions-blink-red/-yellow rather than declaring a second pair, and every state dot rule excludes .tab-alert-action/.tab-alert-idle by hand, because those alert rules are only (0,3,0) and the rail's are (0,5,1)+. There is no .active rule in that block on purpose: .session-tab.active already paints border/background/box-shadow !important. ⚠️ Row ORDER is a third rail attribute (tabRailSort: 'activity'|'manual', data-tab-rail-sort, default activity): a sorted rail answers the home screens' question with the home screens' answer, CodemanSessionOrder over rows classified by _mobileOverviewState (_tabRailSortOrder in app.js). ⚠️ It is applied as the flex order property, never by reordering the DOM: #sessionTabs stays in sessionOrder, so the Alt+N badge (_tabIdx, which therefore does NOT run 1,2,3 down a sorted rail — it names a shortcut, not a position), drag-and-drop, the arrow-key walk, the sidebar filter and _scrollActiveTabIntoView() all keep reading the list they always read, and a session changing state moves ONE inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path therefore has to re-apply it (a state flip adds no tab, so the full rebuild is never reached) and an empty string is what clears it when sorting stops. ⚠️ Web tabs are pinned past the cards by a CSS order: 9999 rather than an inline one, since renderWebviewTabs() emits the same markup for every layout; the flex default of 0 would interleave them among the sorted sessions. ⚠️ setupTabDragHandlers() sets draggable="false" and returns while sorting is on: the drop rewrites sessionOrder correctly and the sort then puts the card straight back, so the affordance would be a lie — 'manual' is the way back to drag-reordering. ⚠️ The arrow-key walk is the one place that must follow the SORT rather than the DOM: _tabKeydownHandler steps querySelectorAll order, which is sessionOrder, so on a sorted rail ArrowDown from the top card landed wherever that session sat in the tab order instead of on the card below it. It now sorts its node list by the COMPUTED order first (computed, not inline: web tabs get their order: 9999 from CSS and would otherwise read as 0 and lead the walk). This is the exception that proves the DOM-stays-in-sessionOrder rule: the badge, drag model and Alt+N index all deliberately keep reading the DOM, and only the thing the user steps with their eyes follows the paint.
Phone overview home screen (mobile-overview.js, phones only, per-device mobileOverviewEnabled, default ON): under 600px 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. ⚠️ The gate tests the CLEANED selection, not the raw one (CodemanCopySelection.clean in constants.js, pure; cleanedTerminalSelection() reads the live terminal): xterm returns whole screen ROWS and trims only never-written cells, so the spaces a full-screen TUI paints across the rest of a row are content and reach the clipboard (138 of them per line, measured in a 282-column pane). The clean drops each line's TRAILING run and nothing else. ⚠️ A shared LEADING indent is deliberately NOT stripped, and that is a decision, not a gap: it was built, measured and dropped before #451 merged, because it fired on 73% of real non-TUI text (401,445 windows sampled) and no width threshold separates a TUI margin from content (a claude pane's own margins are 2 and 5 columns; the commonest non-TUI shared run is 4). Do not re-add it without reading the rule in architecture-invariants. ⚠️ Alt+drag COLUMN selections are returned untouched (_activeSelectionMode === 3), and the padding-only clear is FEEDBACK rather than interrupt protection, since a padding-only selection now cleans to '' and falls through to the PTY on its own. → 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
Terminal font weight (terminalFontWeight / terminalFontWeightBold, per-device, default = xterm's own normal/bold): bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare ESC[1m and no colour change, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. CodemanTerminalFont.resolveWeights() (constants.js, pure) resolves both slots, each against its own xterm default, so an unset bold weight can never inherit normal. ⚠️ The @font-face descriptor, not the file, is what the browser synthesizes from: fonts/jetbrains-mono-variable.woff2 carries a wght axis of 100-800, and while styles.css declared it 400 700 every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared 100 800; re-narrowing it silently guts the feature (test/terminal-font-weight.test.ts pins the range). ⚠️ A live save must reach both echo overlays (refreshFont() — they cache terminal.options.fontWeight and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) and open Agent Teams panes (they read their options at construction, exactly like applyTerminalSkin() propagates). ⚠️ _awaitTerminalFont() is deliberately untouched: CharSizeService measures through the CSS font shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
Theme skins / branding / i18n: skin selects a palette via data-skin on <html>, applied by an inline pre-paint script in index.html reading localStorage['codeman:skin'] to avoid a flash of wrong theme. ⚠️ A skin is four things that must stay in sync, and missing any one degrades silently: the html[data-skin="…"] token block in styles.css, the xterm ANSI palette in terminal-ui.js, the pre-paint allowlist, and the Settings picker (both in index.html). test/skin-themes.test.ts is the static guard. Light skins additionally need color-scheme: light and xterm minimumContrastRatio: 4.5, and applyTerminalSkin() must call the local-echo overlay's refreshFont() because it caches the terminal fg/bg. displayName changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. language (en/zh-CN) keeps English as the canonical source so live switching stays reversible. User display names flow through textContent/attribute APIs and the server title's HTML escaper, never innerHTML. → architecture-invariants#theme-skins
Foldable settings identity: responsive layout is width-driven via MobileDetection.getDeviceType(), but the localStorage namespace uses MobileDetection.isHandheldDevice() so an unfolded Android foldable keeps codeman-app-settings-mobile. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: OPPO Find N5 (unfolded) in test/mobile/devices.ts. → architecture-invariants#foldable-settings-identity
Folding devices: a fold is not a keyboard, and dialogs avoid the hinge (Apple's Designing for iPhone Duo): ⚠️ A visual-viewport resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is never the virtual keyboard, which only ever takes height. handleViewportResize() read any height drop over 150px as the keyboard appearing, so closing an iPhone Duo (626→466pt wide, 890→678pt tall) latched keyboardVisible with no keyboard on screen: the accessory bar appeared, main grew 84px of dead padding, and updateAppHeight() (which bails while the keyboard is up) stopped refreshing --app-height. The latch is STICKY, since clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at, so it survived until the device was opened again, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ init() must seed lastViewportWidth, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ The hinge is a reserved region. The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and --fold-inline-end/--fold-block-end (styles.css) measure the strip to keep clear: 0px on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all position: fixed; inset: 0 flex boxes, and each shrinks its CONTENT box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. ⚠️ Each rule RE-STATES the overlay's own gutter (a later padding-right longhand beats the earlier padding shorthand it composes with), and the palette needs the compound .modal.command-palette-modal because mobile.css loads later and pads it with a shorthand under 768px. test/foldable-layout.test.ts DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. ⚠️ With the keyboard up, a shape change baselines to window.innerHeight (the layout viewport, keyboard-free on both engines), never the shrunk visual height: the shrunk baseline made the settle event that follows every rotation or fold read as the keyboard closing, and the layout could not recover, since no further drop could re-arm the show branch. ⚠️ A base gutter that a LATER @media block overrides needs its own fold restatement in that block, on a ZERO base: the phone-width path picker and path preview drop to padding: 0 under 600px, and the unconditional fold rules at the end of the file put 16px and 18px back on every phone (measured at 393 and 500). The palette's compound rule lives INSIDE the 600-768px band whose mobile.css shorthand it composes with, because outside it there is no side gutter and the addition pushed the shell 6px off centre; the response viewer's tabletop cap has a twin at the end of mobile.css, whose phone block (under 600px) otherwise outranks it. test/foldable-layout.test.ts simulates the cascade across BOTH stylesheets at every breakpoint, with and without the fold rules, so a moved gutter or an unscoped composition fails there rather than on hardware nobody has. Profiles: iPhone Duo (outer) / iPhone Duo (inner) in test/mobile/devices.ts; unit coverage in test/viewport-shape-change.test.ts. → architecture-invariants#folding-devices
WebGL renderer toggle (webglRendererEnabled, per-device): the GPU-stall watchdog's sticky codeman-webgl-disabled marker survives page loads and is cleared only by an explicit OFF→ON save or ?webgl=force. ?nowebgl forces the DOM renderer per-load. → architecture-invariants#webgl-renderer-toggle
Shell keyboard accessory bar + one-shot Ctrl (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: 599px) btn-shell is display:none and btn-enter takes its slot (order: 4); starting a shell moved into the Run dropdown (Terminal / Shell → setRunMode('shell') → run() → runShell(), button label "Run SH"). runMode is z.string().max(20) server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
⚠️ sendEnterKey() MUST go through terminal._core.coreService.triggerDataEvent('\r', true) — not sendInput(), and never a raw POST to /api/sessions/:id/input. localEchoEnabled defaults to MobileDetection.isTouchDevice(), so on every phone the characters you type are buffered in the LocalEchoOverlay and have never reached the PTY; the onData Enter branch in terminal-ui.js is what flushes pendingText first and only then sends \r (after an 80ms delay so text lands first). Sending a bare \r submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. KeyboardAccessory.sendKey() is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
⚠️ Skin overrides outrank plain class rules. styles.css nests its skin block inside html:not([data-skin="og"]) { … }, so a bare .btn-toolbar rule in there resolves to specificity (0,2,1) and beats a .btn-toolbar.btn-x rule (0,2,0) in mobile.css regardless of load order. Toolbar-button colors set from mobile.css therefore need !important — that is why mobile.css leans on it so heavily. Symptom: only your !important properties land and everything else silently renders in generic toolbar grey.
Connection-loss UI (computeConnectionLossUi() in constants.js, writer _updateConnectionLossUi() in app.js): the service worker serves the cached app shell, so an unreachable server (phone off the tailnet, VPN down, server stopped) used to render a normal-looking empty dashboard whose only tell was the 8px header dot, which reads as "no sessions", not "no connection". Two surfaces now: a full-screen overlay while no server state has loaded this page load (nothing behind it is worth preserving), and a non-blocking banner once it has (the terminal scrollback stays readable). ⚠️ A 2.5s grace is load-bearing: a COM deploy restarts the server and SSE is back in ~200ms, and a banner on every deploy trains the user to ignore it. navigator.onLine === false skips the grace, since that is never a blip. Retry re-arms SSE and the terminal WS (planWsReconnect can 'give-up', and the SSE backoff caps at 30s).
SSE staleness watchdog (computeSseStale() in constants.js, _checkSseStale() + a 5s interval in app.js): an EventSource 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), split picker menu (1000, .split-picker-menu), 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), the custom-model center-status banner (10001, .center-status-banner — [hidden] must re-assert display: none over its own display: flex, same trap as .home-sessions[hidden], or dismiss() leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, #customModelSwapConfirmModal/#customModelContextWarningModal — must clear both the plain .modal z-index of 1000 and the center-status banner it can appear over), 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), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; _installShiftDragSelection), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
Security
Full model: docs/security-architecture.md (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). Layer-by-layer detail with the history behind each: architecture-invariants#security-layers.
| Layer | The rule |
|---|---|
| Auth | Optional HTTP Basic via CODEMAN_USERNAME (default admin) / CODEMAN_PASSWORD. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts) |
| Network bind | Defaults to loopback. Non-loopback without a password starts but warns loudly. Classifier: network-auth-policy.ts |
| Host guard | Always-on Host-header allowlist blocking DNS rebinding. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix |
| CSRF / Origin | Always-on cross-site Origin guard on state-changing requests. A missing Origin is allowed so curl/CLI and hooks keep working. ⚠️ The body parser keeps text/plain RAW; auto-JSON-parsing it enabled simple-request CSRF |
| QR Auth | Single-use 6-char tokens (60s TTL) for tunnel login. See docs/qr-auth-plan.md |
| Sessions | 24h cookie (codeman_session), auto-extend, device context audit |
| Rate limit | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
| Hook bypass | /api/hook-event + /api/status-telemetry skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires X-Codeman-Hook-Secret unconditionally (Codeman cannot detect a user's own loopback reverse proxy) |
| Lost-frame page | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a GET/HEAD carrying Sec-Fetch-Dest: iframe|frame, Accept: text/html and mode navigate (or none), for a path that is NOT a registered route (never /api/, /ws/, /q/), is answered BEFORE the credential checks with the static web-tab recovery page (lostWebviewFramePage: no reflected input, default-src 'none' plus its own script hash, no-store). / is the one registered route also admitted, only when the request carries neither codeman_session nor Authorization (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly / and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in docs/api-reference.md. Pinned by test/webview-auth-exemption.test.ts + test/webview-lost-root-frame.test.ts |
| Tunnel | Enabling a tunnel refuses without CODEMAN_PASSWORD unless exposure is acknowledged via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 or the per-request acknowledgeUnauthTunnel:true action field (never persisted) |
| Validation | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*/ANTIGRAVITY_*/PI_*/GROK_*/XAI_*/DSH_*/DEEPSEEK_*) |
| Headers | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
Security-relevant env vars: CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway).
SSE Event Registry
161 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, 161 = 161, no drift either direction). ⚠️ hook:agent_working is the one hook event with no Claude Code hook behind it — the DeepSeek status bridge reports it (see External CLI modes). The backend file's @fileoverview carries the per-category breakdown, including the two Web tab events.
API Routes
~236 handlers across 27 route files in src/web/routes/: system (56), sessions (37), cases (34), 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), custom-model (6), reboot-restore (3), me (2), teams (2), tab-layout (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, owner tab layouts), mux-sessions.json (tmux recovery), settings.json (user prefs), push-keys.json + push-subscriptions.json, session-lifecycle.jsonl (audit log), update-status.json (self-updater progress, polled across the service restart), docker-env-applied.json (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by Start-Codeman.sh, read by the self-updater's environment gate), docker-build-source.json (Compose deployment only: the checkout's HEAD commit and package-lock.json hash the codeman-node-modules/codeman-dist volumes currently reflect, written by both Start-Codeman.sh and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), linked-cases.json, webviews.json (saved web-tab dashboard URLs), remote-hosts.json + remote-cases.json, docker-hosts.json + docker-cases.json + docker-exports/, subagent-window-states.json + subagent-parents.json (subagent window layout, GET/PUT /api/subagent-window-states/-parents), hook-secret (per-instance), users.json (multi-user, mode 0600) + admin-audit.jsonl, intents.json (Read My Mind intent profiles, mode 0600), certs/ (self-signed TLS for --https), .env (CODEMAN_USERNAME/PASSWORD fallback for the codeman attach CLI). 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), and strips the three instance-selection vars CODEMAN_INSTANCE/CODEMAN_DATA_DIR/CODEMAN_TMUX_SOCKET (#356/#371; test/test-env-isolation.test.ts pins the list, and its STATIC half reads setup.ts so a dropped delete fails everywhere rather than only on a box that exports the var). ⚠️ CODEMAN_DATA_DIR is the one that matters: getDataDir() reads it as an ABSOLUTE override before it ever looks at homedir(), so one inherited from the shell (a second instance, a beta run, a shell left over from codeman web -d) bypasses the temp HOME entirely, and a bare suite run once overwrote the real remote-hosts.json with a route test's fixture. os.homedir() itself DOES follow $HOME, so the temp HOME is what redirects everything else; CODEMAN_INSTANCE must be stripped in the setup file and never in a hook, because config/instance.ts captures it into a module-level const on first import. Tests that delete case trees go through safeRmHomeTree() (test/mocks), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting ~/codeman-cases. ⚠️ 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/ (138 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, ~112KB) 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). Its CLI knowledge is a GENERATED block (npm run generate:cli-catalog, markers in the file), not a hand-written list: detection, the install menu and the closing reminder all read it, which is what stops the class of bug upstream b6d0f1fa fixed by hand (a user with only omp installed being told no AI CLI was found). ⚠️ It must stay bash 3.2 clean — macOS ships it and the documented install is curl | bash under set -euo pipefail, so declare -A, mapfile, namerefs, ${x,,} and here-strings are all fatal there; CI runs bash -n plus a real bash:3.2 container, since expanding an EMPTY array under set -u is a runtime abort bash -n cannot see. ⚠️ It executes ONLY commands from the embedded block (CLI_INSTALL_CMD_TRUSTED) — there is no network fetch of the catalogue at install time to worry about at all.
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.