Files
Codeman/CLAUDE.md
T
Codeman maintainer 2fdf7dabac docs: sync READMEs with 1.6.0 (remote SSH, session manager, permissions); fix installer prompts under curl|bash
README.md + README.zh-CN.md:
- New "Remote SSH Sessions" section (durable remote tmux, auto-reconnect,
  discover/attach with detach-not-kill, shared sessions, injection-safe ssh)
- New "Session Manager & Command Palette" subsection (pinning survives kill,
  name retention on resume, cross-device tab order sync)
- Multi-user quick start right after installation (users add + --multiuser),
  and the zh-CN README gains the full Multi-User Mode section it was missing
- Security: document the configurable startup permission mode (skip/auto/
  normal/allowedTools) and the multi-user auto downgrade
- Cron header button noted as opt-in (Header Displays); API section counts
  refreshed (~190 handlers / 20 route modules) with pin, session-order and
  unified endpoints; Development now recommends npm run test:ci

CLAUDE.md (/init audit): session-order.ts in the Session row, PR #157
session-manager polish appended to the unified-list pattern, opt-in Cron
button documented, route/SSE counts refreshed (20 modules, ~188 handlers,
~146 events)

install.sh: the post-install "How would you like to run Codeman?" menu (and
the CLI picker + yes/no prompts) read from stdin, which under curl | bash is
the pipe, so choices were impossible and the script silently fell through to
the default. New has_tty()/read_reply() helpers prompt via /dev/tty whenever
a real terminal exists (same approach the sudo path already used) and only
fall back to defaults when there is genuinely none, now with an info line
saying so. Verified both paths with a pty harness (script(1)) and setsid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:17:32 +02:00

105 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Quick Reference

Task Command
Dev server npm run dev (or npx tsx src/index.ts web)
Type check tsc --noEmit
Lint npm run lint (fix: npm run lint:fix)
Format npm run format (check: npm run format:check)
Single test npm test -- test/<file>.test.ts (or npx vitest run --config config/vitest.config.ts test/<file>.test.ts) — ⚠ never run bare npm test, see Testing section
Build npm run build (esbuild via scripts/build.mjs, NOT tsc — tsc --noEmit is type-check only)
Production npm run build && systemctl --user restart codeman-web

CRITICAL: Session Safety

You may be running inside a Codeman-managed tmux session. Before killing ANY tmux or Claude process:

  1. Check: echo $CODEMAN_MUX - if 1, you're in a managed session
  2. NEVER run tmux kill-session, pkill tmux, or pkill claude without confirming
  3. Use the web UI or ./scripts/tmux-manager.sh instead of direct kill commands

CRITICAL: Always Test Before Deploying

NEVER COM without verifying your changes actually work. For every fix:

  1. Backend changes: Hit the API endpoint with curl and verify the response
  2. Frontend changes: Use Playwright to load the page and assert the UI renders correctly. Use waitUntil: 'domcontentloaded' (not networkidle — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
  3. Only after verification passes, proceed with COM

The production server caches static files for 1 year, immutable (maxAge: '1y' in server.ts). To avoid stale frontend after a deploy, renderIndexHtml runs cacheBustAssets(html) — it appends ?v=<mtime> to every same-origin .js/.css reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because index.html is served no-cache, a normal reload now picks up edited modules/styles — no hard refresh needed (the gesture bundle is injected separately with its own ?v=). If you add an asset referenced by an absolute URL or from JS rather than a <script>/<link> tag, it won't be auto-busted.

COM Shorthand (Deployment)

Uses Semantic Versioning (MAJOR.MINOR.PATCH) via @changesets/cli. What SemVer actually covers (the CLI + documented env vars are public; the HTTP/SSE API, on-disk state, and experimental features are internal/unstable) is defined in docs/versioning-policy.md. Security reporting + known limitations live in SECURITY.md.

When user says "COM":

  1. Determine bump type: COM = patch (default), COM minor = minor, COM major = major

  2. Create a changeset file (no interactive prompts). Write a .md file 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)
    CHANGESET
    

    Replace patch with minor or major as needed. Include "xterm-zerolag-input": patch on a separate line if that package changed too.

  3. Consume the changeset: npm run version-packages (auto-bumps package.json files, updates CHANGELOG.md, runs npm install --package-lock-only, and verifies lockfile sync via scripts/check-lockfile-sync.mjs — all in one command; never hand-edit CHANGELOG.md or package-lock.json versions)

  4. Sync CLAUDE.md version: Update the **Version** line below to match the new version from package.json

  5. Commit and deploy: git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web

  6. Wait for CI: after git push, TWO workflows fire per master push — CI and Release (the npm publish + GitHub release). List both runs for the pushed commit with gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName and watch EACH with gh run watch <id> --exit-status. Confirm both pass before considering the release done (gh run list -L 1 returns only one of the two).

CI runs npm run check:lockfile on every push/PR, so lockfile drift fails the build even if the version-packages script is bypassed.

Version: 1.6.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), and Gemini (Google) CLIs via pluggable CLI resolvers (SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini').

TypeScript Strictness (see tsconfig.json): noUnusedLocals, noUnusedParameters, noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch, allowUnreachableCode: false, allowUnusedLabels: false.

Requirements: Node.js 22+, Claude CLI, tmux

Git: Main branch is master. SSH session chooser: sc (interactive), sc 2 (quick attach), sc -l (list).

Additional Commands

npm run dev = dev server. Default port: 3000 (override with --port or the CODEMAN_PORT env var). To run this beta isolated alongside a prod Codeman, use scripts/run-beta.sh (sets CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000). Commands not in Quick Reference:

Task Command
Dev with TLS npx tsx src/index.ts web --https
Override window title hostname npx tsx src/index.ts web --title-hostname <name> (default: os.hostname() — codeman:<name> is used for tab title, title-flash, and OS desktop notification prefix)
Bind a non-loopback host npx tsx src/index.ts web --host 0.0.0.0 (or -H; env CODEMAN_HOST; default 127.0.0.1). Without CODEMAN_PASSWORD it starts but warns loudly — see Common Gotchas + docs/security-architecture.md
Continuous typecheck tsc --noEmit --watch
Watch-mode test npm run test:watch -- test/<file>.test.ts (always pass a file — bare watch includes the browser suites)
Test coverage npm run test:coverage
Dead-code sweep npm run knip (config in knip.json)
Rebuild gesture overlay npm run build:gesture (esbuild packages/gesture-control/src/codeman/entry.ts → src/web/public/gesture/gesture-codeman.js; commit the result)
Build the docker agent image node scripts/build-agent-image.mjs (builds codeman/agent:base from docker/agent.Dockerfile; prerequisite for Docker cases; --engine/--image/--no-cache)
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)
CI-equivalent test sweep npm run test:ci (full suite minus browser/perf — see Testing)
Production start npm run start
Production logs journalctl --user -u codeman-web -f

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 3 Playwright tests). Tests are tmux-safe in CI: TmuxManager no-ops all shell commands under VITEST (see Testing).

Code style: Prettier (singleQuote: true, printWidth: 120, trailingComma: "es5"). 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/**.

Common Gotchas

  • Single-line prompts only — writeViaMux() sends text+Enter separately; multi-line breaks Ink
  • ESM only — Never require(), use await import(). tsx masks CJS/ESM issues in dev but production breaks
  • Package ≠ product name — npm: aicodeman, product: Codeman. Release renames tags accordingly. Both aicodeman and codeman bin aliases are installed (package.json bin)
  • Global regex lastIndex — Shared g-flag patterns in loops must reset lastIndex = 0 first, or use the execPattern() helper in utils/regex-patterns.ts (resets automatically)
  • envOverrides flow CLAUDE_CODE_* / OPENCODE_* / CODEX_* / GEMINI_* / GOOGLE_* env vars — Set via POST /api/sessions { envOverrides }, stored on Session._envOverrides, exported by tmux-manager.buildEnvExports() at spawn time, persisted in SessionState.envOverrides. Do NOT write these to <case>/.claude/settings.local.json — that's the old path and creates UI/disk drift. (GOOGLE_* is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.)
  • Effort is NOT an env var — never carry effort as CLAUDE_CODE_EFFORT_LEVEL: the env var hard-locks effort and blocks in-session /effort switching (incl. ultracode). It flows as the dedicated effort payload field → Session._effort → claude --effort <level> for regular levels incl. max (the settings effortLevel key is enum(["low","medium","high","xhigh"]).catch(undefined) — max gets SILENTLY dropped there), or claude --settings '{"ultracode":true}' for ultracode (rejected by --effort). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in applyEnvOverrides(). See buildEffortCliArgs() in session-cli-builder.ts, tests in test/effort-injection.test.ts
  • Model choice flows via settings.local.json, NOT --model or env — the App Settings Claude Model picker (claudeModel in settings.json) is read by session-ui.js at session create (wins over the legacy 1M-Opus toggles opusContext1m/opusContext1mEnabled), sent as the modelOverride payload field, and updateCaseModel() (hooks-config.ts) writes/deletes the model key in <case>/.claude/settings.local.json. This is the intended exception to the envOverrides rule above: model legitimately lives in settings.local.json (a soft default — in-session /model still works); env vars do not
  • Multi-CLI prefix discipline — Codeman supports Claude Code, OpenCode, Codex, and Gemini (claude-cli-resolver.ts / opencode-cli-resolver.ts / codex-cli-resolver.ts / gemini-cli-resolver.ts); env-var prefix is CLI-specific (CLAUDE_CODE_* vs OPENCODE_* vs CODEX_* vs GEMINI_*) and the ALLOWED_ENV_PREFIXES allowlist in schemas.ts enforces this. Gemini additionally allowlists the broad GOOGLE_* namespace (intentional — Vertex AI auth uses GOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI etc.; it's the loosest allowlist entry, affecting only the user's own spawned CLI). When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See docs/opencode-integration.md for the resolver design pattern
  • Zod .optional() rejects null — accepts undefined only. When the frontend builds a request body with JSON.stringify, an explicit null field is preserved on the wire and fails validation with INVALID_INPUT. Convert null → undefined before stringifying (e.g. field: value ?? undefined), or declare the schema .nullish(). Real bugs caused: 0.6.4 (durationMinutes for ∞ respawn), and the same shape pattern hit opusContext1mEnabled in 0.6.3
  • xterm-zerolag-input is single-source — edit the package, then rebuild the bundle — the local-echo overlay source lives ONLY in packages/xterm-zerolag-input/src/ (zerolag-input-addon.ts; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended window.LocalEchoOverlay aliases) into the gitignored src/web/public/vendor/xterm-zerolag-input.js by scripts/postinstall.js (for dev/tsx) and into dist/.../vendor/ by scripts/build.mjs (the xterm-zerolag-input esbuild step, for prod). app.js only consumes it via new LocalEchoOverlay(terminal) — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (npm install reruns postinstall; npm run build for prod); never hand-edit app.js for overlay behavior or commit the gitignored vendor bundle. A public-API break in the package still warrants a separate xterm-zerolag-input version bump in the changeset. Always test on mobile after touching it. See docs/local-echo-overlay-plan.md.
  • Default bind is loopback-only; non-loopback without a password starts but warns — since COD-29 (PR #107) the web server defaults to --host 127.0.0.1 (was 0.0.0.0). As of 0.9.0 binding a non-loopback host (--host/-H/CODEMAN_HOST) without CODEMAN_PASSWORD no longer refuses to start — it starts and prints a loud warning listing the fixes (set CODEMAN_PASSWORD, bind loopback + tunnel/tailscale serve, or --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge → terser note). Host classification is isLoopbackBindHost() in network-auth-policy.ts; the warn-vs-start logic is in server.ts start(); flags wired in cli.ts. ⚠️ Operational note: the production systemd unit runs node dist/index.js web --https with no --host, so it binds localhost only — reach it remotely via tailscale serve/tunnel to 127.0.0.1, or add Environment=CODEMAN_HOST=0.0.0.0 + Environment=CODEMAN_PASSWORD=… to ~/.config/systemd/user/codeman-web.service. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → 127.0.0.1) but NOT by a browser hitting the box's LAN IP. Auth user defaults to admin. Full model: docs/security-architecture.md.
  • Instance isolation / multi-instance attach danger — data dir (~/.codeman) and tmux socket (tmux -L codeman) are PROCESS-WIDE and shared by every Codeman on the machine, derived from CODEMAN_INSTANCE via src/config/instance.ts (getDataDir()/dataPath()/DEFAULT_TMUX_SOCKET). ⚠️ A 2nd instance on the SAME socket discovers and attaches PTYs to the first instance's live sessions (tmux -L codeman attach-session …), resizing/mutating them — $HOME isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct CODEMAN_INSTANCE (scopes BOTH dir+socket: ~/.codeman-<name> + -L codeman-<name>), or set CODEMAN_TMUX_SOCKET + CODEMAN_DATA_DIR individually. CODEMAN_INSTANCE defaults to empty = the production layout (~/.codeman, -L codeman, port 3000), so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with scripts/run-beta.sh (CODEMAN_INSTANCE=beta + CODEMAN_PORT=5000) — it never collides with prod's data dir/socket/port. Any new ~/.codeman/... path MUST go through dataPath(), never join(homedir(), '.codeman', …).
  • Headless screenshots: deviceScaleFactor MUST be 1, and write unique filenames — scripts/capture-real-overview.mjs (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: (1) DSF=2 doubles the console font. xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under deviceScaleFactor: 2, while STILL reporting nominal cell dims (terminal.cols/_renderService.dimensions.css.cell say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to DSF=1 (script does); the image is 1× res but the font is true-to-browser. (2) Stable filenames → stale renders. Overwriting a fixed path (claude-overview.png) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/immutable cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped claude-overview-<ts>.png per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: file-routes previews send Cache-Control: no-cache and /api/screenshots/:name sends none. The one real Codeman-side footgun: server.ts serves non-content-hashed static assets public, max-age=31536000, immutable, and cacheBustAssets() only rewrites .js/.css refs — a stable-named image referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed localStorage codeman:skin, codeman-font-size, and the desktop codeman-app-settings blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).

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
Session src/session.ts ★, src/session-manager.ts, src/session-auto-ops.ts, src/session-cli-builder.ts, src/session-lifecycle-log.ts, src/session-task-cache.ts, src/session-pty-exit-breaker.ts, src/session-order.ts (pure tab-order normalize/merge helpers, COD-131), src/usage-limit-patterns.ts, src/usage-telemetry.ts; src/services/unified-session-service.ts (merges live/persisted/lifecycle/transcript rows for GET /api/sessions/unified)
Mux src/mux-interface.ts, src/mux-factory.ts, src/tmux-manager.ts ★
Respawn src/respawn-controller.ts ★ + 4 helpers (-adaptive-timing, -health, -metrics, -patterns) Read docs/respawn-state-machine.md first
Ralph src/ralph-tracker.ts ★, src/ralph-loop.ts + 5 helpers (-config, -fix-plan-watcher, -plan-tracker, -stall-detector, -status-parser) Read docs/ralph-wiggum-guide.md first
Orchestrator src/orchestrator-loop.ts, src/orchestrator-planner.ts, src/orchestrator-verifier.ts Read docs/orchestrator-loop-architecture.md first
Cron src/cron/cron-service.ts, src/cron/cron-time.ts (pure next-run math), src/cron/cron-input.ts Cron-style CronJobs. Read docs/cron-discovery.md first; distinct from legacy ScheduledRun (/api/scheduled) — see Key Patterns
Agents src/subagent-watcher.ts ★, src/team-watcher.ts, src/bash-tool-parser.ts, src/transcript-watcher.ts, src/workflow-run-watcher.ts workflow-run-watcher is STANDALONE (never touches subagent-watcher) — see Key Patterns
AI src/ai-checker-base.ts, src/ai-idle-checker.ts, src/ai-plan-checker.ts
Tasks src/task.ts, src/task-queue.ts, src/task-tracker.ts
State src/state-store.ts, src/run-summary.ts, src/session-lifecycle-log.ts
Infra src/hooks-config.ts, src/push-store.ts, src/tunnel-manager.ts, src/image-watcher.ts, src/file-stream-manager.ts, src/remote-hosts.ts (remote SSH hosts/cases — see Key Patterns)
Search src/search-service.ts Pure in-memory core for GET /api/search — see Key Patterns
Attachments src/attachment-registry.ts, src/attachment-magic.ts, src/generated-artifact-attachments.ts (Codex Saved to: artifacts), src/session-attachment-history.ts, src/document-preview-cache.ts, src/document-thumbnailer.ts, src/document-conversion-limiter.ts, src/config/attachment-guard.ts See Key Patterns
Plan src/plan-orchestrator.ts, src/prompts/*.ts, src/templates/ (claude-md.ts + case-template.md, the CLAUDE.md scaffold generated into new cases)
Web src/web/server.ts ★, src/web/sse-events.ts, src/web/routes/*.ts (20 route modules + barrel; session-routes.ts ★), src/web/route-helpers.ts, src/web/ports/*.ts, src/web/middleware/auth.ts, src/web/schemas.ts, src/web/self-update.ts, src/web/plan-usage-latest.ts, src/web/ws-connection-registry.ts (per-tab WS supersede), src/web/heic-jpeg-converter.ts + heic-jpeg-worker.ts (HEIC→JPEG off-thread)
Frontend src/web/public/app.js (~4K lines, core) + 6 infra modules (constants.js, mobile-handlers.js, voice-input.js, notification-manager.js, keyboard-accessory.js, sanitize-html.js — DOMPurify mXSS allowlist, COD-56) + 9 domain modules (terminal-ui.js, respawn-ui.js, ralph-panel.js, orchestrator-panel.js, ultracode-panel.js, cron-ui.js, settings-ui.js, panels-ui.js, session-ui.js) + 6 feature modules (ralph-wizard.js, api-client.js, subagent-windows.js, ultracode-windows.js, input-cjk.js, image-input.js) + sw.js ultracode-windows.js = floating run windows w/ tab connector lines (additional to the dock panel)
Types src/types/index.ts (barrel) → 18 domain files (incl. workflow-run.ts, search.ts, cron.ts); 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 for xterm.js; single-source, bundled to the gitignored vendor/xterm-zerolag-input.js and consumed by app.js (see Gotchas). packages/gesture-control/ (codeman-gesture-control) — hand-tracking overlay source; built to src/web/public/gesture/gesture-codeman.js via npm run build:gesture (see Frontend → Gesture control).

Config: src/config/ — 15 files, no barrel (index.ts) exists; import from the specific file.

Utilities: src/utils/ — re-exported via index. Key: CleanupManager, LRUMap (⚠ NOT in the barrel — import from ./utils/lru-map.js directly), StaleExpirationMap, BufferAccumulator, stripAnsi, Debouncer, KeyedDebouncer. Also: claude-cli-resolver/opencode-cli-resolver/codex-cli-resolver/gemini-cli-resolver (CLI path resolution), string-similarity (fuzzy matching), regex-patterns (ANSI/token/spinner patterns), assertNever (exhaustive checks), token-validation (auth tokens), nice-wrapper (process priority).

Data Flow

  1. Session spawns claude --dangerously-skip-permissions via node-pty
  2. PTY output buffered, ANSI stripped, parsed for JSON messages
  3. WebServer broadcasts to SSE clients at /api/events
  4. State persists to ~/.codeman/state.json via StateStore

Key Patterns

Input: session.writeViaMux() for programmatic/curl input — tmux send-keys -l (literal) + send-keys Enter. Single-line only (fire-and-once). Interactive browser input goes through a durable exactly-once layer: each frame carries a stable clientId + monotonic per-session seq, persisted to localStorage until the server ACKs ({t:'ia',seq} over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. WS resilience (#149): the upgrade URL carries cid = clientId + ':' + perTabNonce, and ws-connection-registry.ts supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare clientId for seq dedup); reconnects back off exponentially (attempts preserved across _connectWs), and the header connection chip renders from a real _wsState lifecycle (connecting/connected/fallback/reconnecting/disconnected).

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

Auto-resume on usage limit ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), usage-limit-patterns.ts (pure, unit-tested) parses the reset time from cleaned output; SessionAutoOps arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + continue. Still-limited responses re-arm the loop (5-min retry on stale times); a working transition cancels it. Claude-mode only (detection rides _processExpensiveParsers). Persists/recovers via SessionState.autoResumeEnabled/autoResumeAt; respawn cycles are blocked while paused (isLimitPaused guard in onIdleDetected — prevents /clear from wiping the paused conversation). Endpoint: POST /api/sessions/:id/auto-resume; SSE: session:limitPauseScheduled/limitResume/limitResumeCancelled. Tests: test/usage-limit-patterns.test.ts, test/session-auto-resume.test.ts.

Plan-usage chip (statusLine telemetry, opt-in showPlanUsageLimits, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured statusLine.command on each render; on Pro/Max it carries a rate_limits object (five_hour/seven_day windows only — no Opus weekly field — each {used_percentage 0-100, resets_at epoch-SECONDS}). Codeman injects its OWN statusLine exporter (generateStatusLineCommand() in hooks-config.ts, identified by the /api/status-telemetry marker — it only ever adds/updates/removes a statusLine that is ours, never a user's hand-authored one) that POSTs the blob to POST /api/status-telemetry. That route (auth-exempt like /api/hook-event — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via usage-telemetry.ts (pure, unit-tested), broadcasts SSE session:statusTelemetry (de-duped per session by telemetrySignature since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to print-through (so injecting our statusLine doesn't blank the in-terminal footer). plan-usage-latest.ts holds the process-wide last value, replayed in the SSE init snapshot (getLightState) so the header chip (#planUsageChip, toggled by showPlanUsageLimits in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. Distinct from auto-resume (which reacts to the limit message; this proactively shows the live %). Design: docs/usage-limits-display-plan.md. Tests: test/usage-telemetry.test.ts.

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 (cron-style CronJobs): saved, named jobs with a recurring schedule (once/interval/daily/weekly), enable/disable, Run Now, next-run calc, and per-job run history (CronJobRun). ⚠️ Distinct from the legacy ScheduledRun (/api/scheduled, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the Scheduled* names, the recurring-job feature is Cron*. CronService (src/cron/cron-service.ts) owns CRUD + the 30s background due-tick (tickDueJobs, registered via cleanup.setInterval in server.ts; init() recomputes nextRunAt on boot) and reuses the existing session layer (create → addSession → setupSessionListeners → startInteractive/startShell → prompt via writeViaMux/write) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in cron-time.ts (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = lastDueKey (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. once jobs self-disable after firing (completedOnce). Persisted via AppState.cronJobs/cronJobRuns (StateStore accessors). Routes /api/cron/jobs* + /api/cron/runs (cron-routes.ts, CronPort); schema CronJobSchema (cross-field superRefine; the .partial() update schema does NOT re-run it); SSE cron:*. Frontend cron-ui.js (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: test/cron-time.test.ts, test/cron-service.test.ts. Design: docs/cron-discovery.md.

Remote sessions (SSH): Sessions can run the agent inside a durable tmux -L codeman-remote new-session -A on a remote host so it survives the SSH drop (COD-104), and can also discover + attach to codeman-* sessions another Codeman launched there — attached (owned:false) sessions detach, never kill on tab close (COD-105). Shared/collaborative (COD-106): remote set-options are scoped per-session (never -g) and window-size latest lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. Auto-reconnect (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch remoteAutoReconnect, default ON). Owned sessions propagate kill-session to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe buildSshConnectionArgs() — every user field (-J jumpHost, -i identity, -o) is shellescaped; never hand-build an ssh line elsewhere. Full design: docs/remote-sessions.md.

External CLI modes (OpenCode, Codex, Gemini): isExternalCliMode() in session.ts (mode === 'opencode' || 'codex' || 'gemini') gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes require tmux — no direct PTY fallback — because secrets are injected via tmux setenv (socket-scoped ${this.tmux()} setenv, never on the spawn command line): OpenCode gets OPENCODE_CONFIG_CONTENT etc., Codex gets OPENAI_API_KEY/CODEX_API_KEY/CODEX_HOME (setCodexEnvVars), Gemini gets GEMINI_API_KEY/GOOGLE_API_KEY/GOOGLE_CLOUD_PROJECT/GOOGLE_APPLICATION_CREDENTIALS/GOOGLE_GENAI_USE_VERTEXAI etc. (setGeminiEnvVars, all in tmux-manager.ts). Codex specifics: command built by buildCodexCommand() (--model, resume <id>, --dangerously-bypass-approvals-and-sandbox from the codexConfig payload / codexDangerouslyBypassApprovals app setting; renderMode is schema-coerced to 'hybrid', the only supported mode). Gemini specifics: command built by buildGeminiCommand() (--skip-trust always, --approval-mode <default|auto_edit|yolo|plan> defaulting to yolo for parity with Claude's --dangerously-skip-permissions, --model, --resume from the geminiConfig payload); availability via GET /api/gemini/status — session/quick-start routes fail with OPERATION_FAILED + install hint (npm install -g @google/gemini-cli) when missing. Codex AND Gemini export COLORTERM=truecolor + unset NO_COLOR (other modes unset COLORTERM); Gemini joins isAltScreenStripMode() (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/3J so scrollback survives). Codex availability via GET /api/codex/status. Frontend: run-mode dropdown → runCodex()/runGemini() in session-ui.js ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ run*() MUST unwrap the {success,data} envelope ((await res.json()).data.available / data.data.sessionId) — reading the raw shape silently breaks the run. Tests: test/run-mode-ui.test.ts + test/gemini-mode.test.ts (vm-sandbox harness, no real DOM).

Remote SSH cases (COD-94/#145): cases can point at a remote host (~/.codeman/remote-hosts.json + remote-cases.json via src/remote-hosts.ts; CRUD under /api/cases — cases route file). A remote session launches a LOCAL tmux pane running ssh <host> that creates a durable REMOTE tmux session on a dedicated socket -L codeman-remote with name codeman-ssh-<id> — deliberately failing the remote Codeman's SAFE_MUX_NAME_PATTERN so a Codeman instance on the target host never adopts it; no -g global tmux options are set remotely. remotePath/identityFile are schema-guarded against shell injection (backticks/$ rejected — same approach as extraSshOptions); remote tmux availability is probed via checkRemoteTmuxAvailable() in quick-start (ssh args carry -o ConnectTimeout=10). Remote claude defaults to exec claude --dangerously-skip-permissions; per-host commands.* override. Session kill best-effort kills the remote tmux too. SessionState.remote/MuxSession.remote round-trip through recovery (restoreMuxSessions passes remote back into the Session constructor). ⚠️ Run flows must route remote cases through POST /api/quick-start (which resolves the remote case and skips LOCAL CLI availability gates) — POST /api/sessions stat-validates workingDir locally and has no caseName. envOverrides/effort/modelOverride/codexConfig/geminiConfig are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: test/remote-hosts.test.ts, test/remote-ssh-options.test.ts.

Docker cases (shipped 1.4.0; user guide docs/docker-cases.md, design docs/docker-cases-plan.md): a case can point at a container instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a LOCATION OVERLAY on cases, never a sixth SessionMode (SessionMode is unchanged). Storage ~/.codeman/docker-hosts.json + docker-cases.json via src/docker-hosts.ts (direct mirror of remote-hosts.ts: readDockerHosts/readDockerCases, toSessionDocker, dockerDisplayPath, and the PURE builders buildDockerBaseArgs/buildDockerCreateArgs/containerApiUrl/hostGatewayAlias/dockerConfigHash). CRUD /api/docker-hosts + /api/cases/docker-link, plus one-click /api/cases/docker-quickcreate (Create New "Run in Docker" checkbox → case folder in CASES_DIR + auto-provisioned shared default host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case q-<name> host), and export/import (/api/docker-cases/:name/export, /api/docker-cases/import, GET/DELETE /api/docker-exports) — all in case-routes.ts. Run flows route through POST /api/quick-start like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). Launch model: exactly one long-lived container per case (codeman-case-<slug>, PID1 sleep infinity under --init); a LOCAL tmux pane runs docker exec -it into a durable in-container tmux on dedicated socket -L codeman-docker, session codeman-dkr-<id8> (deliberately fails SAFE_MUX_NAME_PATTERN so a Codeman running INSIDE the container never adopts it, exactly like remote's codeman-ssh-<id8>). Builders buildDockerLaunchCommand/buildDockerKillCommand in tmux-manager.ts (image-check → docker inspect||create → start → exec, all idempotent). The container is shared by all sessions of the case: buildDockerKillCommand kills ONLY that session's in-container tmux session, NEVER docker stop while siblings remain; docker rm -f happens only on case-delete (plus an instance-scoped boot reaper keyed on the codeman.instance label). Two-layer durability/resume (the central design point): (1) Codeman-PROCESS restart with the container still up → tmux new-session -A reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via claudeDockerPaneCommand() (tmux-manager.ts) — fresh launch claude --session-id <sessionId> || claude --resume <sessionId> (a duplicate --session-id exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume --resume <rid> || --session-id <sid> so a stale id never dead-panes (leading exec is stripped — an exec'd first branch could never fall back); codex resume <id> / gemini --resume keep appendResumeFlag. The resume id rides resumeSessionId through create/respawn options and persists on DockerCase.lastClaudeSessionId via persistDockerCaseClaudeSessionId() (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-/clear switches track; seeded back when resumeOnStart, default true); -A makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). Config drift (dockerConfigHash → codeman.confighash label): quick-start compares via checkDockerConfigDrift() and REFUSES a drifted launch with CONFLICT; the UI confirm calls POST /api/docker-cases/:name/recreate (refused while case sessions are live) which docker rm -fs so the next launch recreates with the new config — host config edits actually take effect. Workspace is a REAL host dir bind-mounted at the SAME absolute path (mirror, dst==src), so Session.workingDir = hostWorkspacePath keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; resolveMuxAttachCwd returns /tmp for docker (the local pane only runs docker exec). Creds arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (~/.claude/projects transcripts; codex sessions/ + history.jsonl for response-viewer/codex resume); everything else is SEEDED (RO mount, copied into container HOME once at launch via [ -e ] || cp; the container refreshes its own copy and never writes back): ~/.claude.json is merged through buildSeamlessClaudeConfig() (forces hasCompletedOnboarding + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus .claude/{.credentials.json,settings.json,stats-cache.json}, plus whole-dir seeds for ~/.gemini/~/.config/{gcloud,opencode} (resolveDockerClaudeArtifacts/resolveDockerCredentialArtifacts in docker-hosts.ts). Bind mounts are physically excluded from docker commit, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY --env OPENAI_API_KEY (no =value); the SEALED profile is mountCredentials:false + network:none. NEVER a create-time -e for secrets, NEVER --privileged, NEVER the docker socket. Hardening on every create: --cap-drop ALL, --security-opt no-new-privileges, --pids-limit, --memory==--memory-swap, non-root via --user <hostUid>:0 (Linux, GID 0 for writable HOME) / --userns=keep-id (podman rootless) / baked uid (Docker Desktop), --pull=never, --init. Base image codeman/agent:base is BUILT LOCALLY from docker/agent.Dockerfile (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, C.UTF-8 locale so tmux/Ink render real box-drawing glyphs; Codeman also sets LANG/LC_ALL at run time for containers built before that line) via scripts/build-agent-image.mjs OR auto-built on first use (1.4.1: ensureAgentBaseImage() in docker-hosts.ts; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, --pull=never stays absolute; build output streams over SSE docker:imageBuildStarted/imageBuildProgress/imageBuildComplete/imageBuildFailed, and quick-create returns imageBuilding:true while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (checkDockerTmuxAvailable), never a silent bare-exec fallback. Hooks + model: the workspace-scaffolding block DOES run for docker (writes .claude/settings.local.json + the CLAUDE.md scaffold into the real host dir), so modelOverride works via settings.local.json — it is a QuickStartSchema field applied for local AND docker quick-starts (updateCaseModel), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); effort/envOverrides/codexConfig/geminiConfig/openCodeConfig stay rejected. In-container hook curls hit containerApiUrl(process.env.CODEMAN_API_URL, engine) (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both host.docker.internal/host.containers.internal (DOCKER_HOST_GATEWAY_ALIASES in network-auth-policy.ts). ⚠️ On a loopback-only bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when CODEMAN_DOCKER_BRIDGE_HOOKS=1 — an opt-in SECOND listener on the docker bridge gateway (_startDockerBridgeHooksListener in server.ts; gateway auto-detected via detectDockerBridgeGateway, or set CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set CLAUDE_CODE_TMPDIR keeps claude launching regardless of workspace path. SessionState.docker/MuxSession.docker round-trip through recovery. Every docker IO path is IS_TEST_MODE (VITEST) no-op'd; the pure builders are unit-tested. Export/import (src/docker-export.ts): full-image (docker commit + save | gzip + workspace tar + manifest) or workspace-only → one portable ~/.codeman/docker-exports/<case>-<ts>.codeman-container.tgz; import validates per-member sha256, traversal-guards the workspace tar, docker loads + quarantine-retags the image (codeman/imported-<case>:<ts>, never overwriting a local tag); a saveImageToTar stream pipeline avoids truncation. GPU passthrough (gpus → --gpus, needs the NVIDIA container toolkit) and elastic disk (no --storage-opt cap, so container storage grows with data). SSE docker:exportComplete/exportFailed/importComplete (both registries). UI in session-ui.js: Create Case Docker tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short (docker) case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs w<n>-<case> via the shared _nextCaseSessionStartNumber() so all tabs follow one naming convention. Tests: test/docker-hosts.test.ts, test/docker-exec-options.test.ts, test/docker-export.test.ts, test/network-host-guard.test.ts.

Unified session list (COD-160/#139): GET /api/sessions/unified?limit=&q= merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in src/services/unified-session-service.ts). Transcript rows are keyed by conversation UUID and folded into their owning session via a claudeSessionId → Codeman id alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike /api/sessions). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): pinning via POST /api/sessions/:id/pin (session:pinned SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); cross-device tab order via PUT /api/session-order (session:orderChanged SSE, persisted in state.json; pure normalizeSessionOrder/mergeSessionOrder in src/session-order.ts: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); firstPrompt is backfilled for sessions whose id != transcript UUID and the most recent prompt (lastPrompt) is shown + searched (COD-140/145).

Hook events: Claude Code hooks trigger via /api/hook-event. Key events: permission_prompt, elicitation_dialog, idle_prompt, stop, teammate_idle, task_completed. See src/hooks-config.ts; upstream hook semantics mirrored in docs/claude-code-hooks-reference.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 breaker: Prevents respawn thrashing. States: CLOSED → HALF_OPEN → OPEN. Reset: /api/sessions/:id/ralph-circuit-breaker/reset. Distinct: PTY-exit breaker (COD-115/118/#147, session-pty-exit-breaker.ts) trips after repeated rapid PTY exits (crash loops on attach), blocks further auto-restarts, broadcasts SSE session:respawnBreakerTripped + push (in PUSH_EVENT_MAP). Reset ONLY via an explicit {clearBreaker:true} body on POST /api/sessions/:id/interactive (sent by the user-facing restart control) — the frontend's auto-reattach in selectSession() sends no body and must never clear it. Sessions also scrub inherited TMUX/TMUX_PANE env so Codeman-in-tmux doesn't nest. Tests: test/respawn-pty-breaker.test.ts.

Full-scrollback replay (COD-164/#148): GET /api/sessions/:id/terminal?full=1 returns the ENTIRE tmux scrollback (capture-pane -e -S -<lines> bounded by the configured history limit, explicit maxBuffer from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (source='mux-full-history' — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests full=1 (one-shot _initialFullBufferLoad flag in app.js); tab switches keep the cheap ?tail= visible-frame path. Tests: test/tmux-capture-full-history.test.ts, test/tmux-scrollback-eol.test.ts.

Self-update (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd. Supervisors: systemd (user unit), launchd (GUI LaunchAgent, gui-domain kickstart), launchd-daemon (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else none → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED scripts/self-update.sh (git checkout <release tag> && npm install && npm run build && restart) that outlives the restart; it writes progress to dataPath('update-status.json'), which the browser polls across the connection drop. Channel = latest codeman@X.Y.Z release tag; dirty trees are auto-stashed. src/web/self-update.ts splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (getInstallInfo/checkForUpdate/startUpdate/reconcileUpdateOnBoot). Routes: GET /api/system/update/check, POST /api/system/update, GET /api/system/update/status. Types: src/types/update.ts. npm installs report as non-updatable.

Attachments (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in file-routes.ts. Registry (attachment-registry.ts): an in-memory map of a stable attachmentId → an absolute, realpath-resolved, extension-allowlisted file path, so browser requests (GET /api/sessions/:id/attachments/:attachmentId/raw) never carry arbitrary absolute paths; POST /api/sessions/:id/attachments registers one. Magic links (attachment-magic.ts): parses codeman://attach?... out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is force-confined to the session workspace (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the attachment:detected SSE event. Security gate is an extension allowlist (isSupportedAttachmentExtension, in the registry/magic modules), not a blocklist; a separate path layer (config/attachment-guard.ts) confines reads to the workspace (attachmentConfineToWorkspace) and blocks sensitive trees (/root, /etc). Previews + thumbnails (COD-38): :attachmentId/preview + :attachmentId/thumbnail (and the workspace-file equivalents file-preview/file-thumbnail) render Office docs/PDFs via external converters (pdftoppm / LibreOffice soffice / Word-COM powershell); document-preview-cache.ts is a shared disk cache (de-dups identical in-flight inputs), document-thumbnailer.ts does best-effort first-page images, and document-conversion-limiter.ts is a global converter-spawn concurrency cap (runWithConversionLimit) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. History drawer (COD-39): session-attachment-history.ts tracks the last ATTACHMENT_HISTORY_LIMIT (100) attachments per session (Session._attachmentHistory, persisted via SessionState.attachmentHistory, replayed so externals re-register on reconnect); GET /api/sessions/:id/attachments is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see mobile-header-buttons-policy test). Session-local files keep using the existing workspace-scoped file-routes paths; the registry is only for explicit live externals. Codex generated artifacts (COD-166/#150, generated-artifact-attachments.ts): codex-mode sessions ALSO scan (ANSI-stripped) output for Saved to: file:///… lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the realpath-resolved path against os.homedir()-anchored ~/.codex marker dirs (symlink escapes fall back to force-confinement); gated to mode === 'codex' only (source is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.

Ultracode / Workflow-run visualization (opt-in showUltracodeAgents, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at ~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json (written only at run end); LIVE in-flight runs exist only as transcript dirs at …/subagents/workflows/wf_<id>/ (journal.jsonl + agent-_.jsonl). workflow-run-watcher.ts (STANDALONE — deliberately never imports/touches subagent-watcher.ts; separate singleton, though it independently reads the same subagents/workflows/ tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion wf\__.jsonappears and supersedes, and broadcasts SSEworkflow:run_discovered/run_updated/run_removed. The watcher is started when either showUltracodeAgentsorultracodeFloatingWindows is on (server.ts isWorkflowAgentTrackingEnabled()returns(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)). Served via GET /api/workflows(optional?minutes=filter) andGET /api/workflows/:runId. Frontend ultracode-panel.jsrenders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-sideagentIdjoin). Additionally,ultracode-windows.jsauto-pops a draggable floating window per active run (gated on a DEDICATEDultracodeFloatingWindowstoggle, default OFF — independent of the dock panel'sshowUltracodeAgents; see \_ultracodeFloatingEnabled()), connected by a glowing line to the originating session tab (resolved by session.claudeSessionId === run.sessionUuid) — same line idiom as subagent windows, drawn into the shared #connectionLinesSVG from the tail of\_updateConnectionLinesImmediate. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an in-page connected transcript window (not a browser popup); both run and transcript windows minimize into the originating session tab as a merged ULTRAbadge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (awindowgrab kind inentry.ts). Types: src/types/workflow-run.ts. Config: src/config/workflow-config.ts.

Cross-session search (COD-113/#133): GET /api/search?q=&types=&limit= federates an in-memory search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private externalPath is never read). Pure core searchSources() in search-service.ts (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); harvestSources() in search-routes.ts gathers the in-memory sources. SearchQuerySchema bounds q (1–200), allowlists types (session,event,file), clamps limit (1–60). Returns the {success,data} envelope. Frontend: history-panel search box in terminal-ui.js. Types: src/types/search.ts.

Multi-user mode (opt-in --multiuser / CODEMAN_MULTIUSER=1, OFF by default; branch feat/multiuser-mode, design docs/multi-user-plan.md): named users with individually scrypt-hashed passwords in ~/.codeman/users.json (via src/user-store.ts: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget touchLastLogin can't clobber a concurrent route write, last-admin invariants). Gated everywhere by isMultiUserMode() (src/config/multiuser.ts); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ 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). Auth: a PARALLEL async branch in middleware/auth.ts (single-user branch untouched) verifies username:password against the store, mints identity-carrying cookies (AuthSessionRecord gains username/role/mustChangePassword), decorates req.authUser (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the mustChangePassword lockbox. Ownership threads through Session.owner (stamped from req.authUser/job.owner at every new Session(), round-tripped via MuxSession.owner on recovery); findSessionOrFail(ctx,id,req) does a NOT_FOUND owner check; list endpoints + getLightState + SSE (deriveSseHint routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to --permission-mode auto (via resolveClaudeModeForUser at all spawn sites, incl. one-shots because buildPromptArgs now respects the session mode), and shell mode / cron launchCommand require the canBypassPermissions grant. Cases live in per-user ~/codeman-users/<name>/cases (resolveCasesDir); a non-admin's workingDir is realpath-confined there; host CRUD is admin-only. Admin API src/web/routes/admin-routes.ts (/api/admin/users*, one-time passwords, audit log admin-audit.jsonl) + self-service /api/me + /api/me/password (me-routes.ts); frontend public/admin-ui.js (identity boot, change-password modal + interceptor, admin Users tab). CLI codeman users add|passwd|list|rm. Per-user session cap via sessionCapacityState/sessionCapacityMessage. Tests: test/user-store.test.ts, test/multiuser-auth.test.ts, test/ownership-scoping.test.ts, test/admin-routes.test.ts, test/admin-ui.test.ts.

Away digest (COD-41/#136): GET /api/away-digest?range=&since=&until=&lastViewed= aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in web/away-digest.ts (resolveAwayDigestRange() validates the window — since-last-visit/1h/today/24h/custom, server-local TZ; buildAwayDigest() classifies). Header-button modal in panels-ui.js (button hidden on phones — regression-guarded). ⚠️ Returns {success:true,digest} (a legacy raw-ish shape, consistent with the other raw GET handlers in system-routes.ts — {entries}/{config}/{files}/getSystemStats()); frontend + tests read .digest. Subagent lookback is a fixed 60-min window regardless of range.

Ralph todo-config (COD-79/#135): 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) → mobile-handlers.js(2) → voice-input.js(3) → notification-manager.js(4) → keyboard-accessory.js(5) → input-cjk.js(5.5) → sanitize-html.js(5.6) → app.js(6) → terminal-ui.js(7) → respawn-ui.js(8) → ralph-panel.js(9) → orchestrator-panel.js(9.5) → cron-ui.js(9.7) → settings-ui.js(10) → panels-ui.js(11) → ultracode-panel.js(11.5) → session-ui.js(12) → ralph-wizard.js(13) → api-client.js(14) → subagent-windows.js(15) → ultracode-windows.js(15.5) → image-input.js(16). input-cjk.js handles CJK IME composition via an always-visible textarea below the terminal (window.cjkActive blocks xterm's onData).

Command palette + shortcut registry (COD-151/153/157/192, #146): Ctrl/Cmd/Alt+K opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by GET /api/sessions/unified); the quick-start case <select> is fronted by a searchable picker (buildCasePickerOptions/formatCasePickerLabel — remote cases render name @ hostId). Shortcuts live in a rebindable registry (DEFAULT_SHORTCUTS/getShortcutRegistry()/matchesShortcutEvent() in app.js; overrides persist under settings.shortcutOverrides via saveAppSettingsToStorage); App Settings → Shortcuts renders capture/disable rows; Ctrl+? opens the registry-driven overlay (footer links to the full #helpModal reference). ⚠️ 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 — keys edited elsewhere (shortcutOverrides, showTokenCount, showCost) need explicit _prev carry-over.

WebGL renderer toggle (#140, webglRendererEnabled): per-device (displayKeys set, stripped from the server payload — NOT in SettingsUpdateSchema, which is .strict()). The GPU-stall watchdog's sticky codeman-webgl-disabled marker survives page loads; it's cleared only by an explicit OFF→ON save transition or ?webgl=force (shouldSkipWebGL in constants.js). ?nowebgl still forces the DOM renderer per-load.

Z-index layers: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, mobile.css), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in b8cb467), log viewers (2000), image popups (3000), local echo overlay (7).

Multi-monitor button (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). app.launchMultiMonitor() (in panels-ui.js) POSTs /api/system/span-displays, which spawns scripts/span-codeman.sh — a fresh, maximized browser --app window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. Opt-in: hidden by default; enable under App Settings → Display → Header Displays ("Multi-monitor Button", showMultiMonitorButton). The button carries a btn-multimonitor--hidden class in the template; renderIndexHtml strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and applyHeaderVisibilitySettings() toggles the same class live on save. Solo (detached) windows hide it via body.solo-mode.

Response-viewer (eye) button (header) is likewise hidden by default — enable under App Settings → Display → Response Viewer (showResponseViewer). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under CODEX_HOME (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in test/routes/session-routes-codex-last-response.test.ts. Purely client-side (no renderIndexHtml step): the template ships with btn-response-viewer-header--hidden and applyHeaderVisibilitySettings() (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is display:inline-flex !important, so an inline style can't override it. showResponseViewer is in the displayKeys per-device set (settings-ui.js), so it does NOT sync across devices.

File Viewer button (header, 1.4.1) is likewise hidden by default: enable under App Settings → Display → Header Displays → File Viewer (showFileViewerButton, also in the per-device displayKeys set). Purely client-side like the response viewer: the template ships btn-file-viewer--hidden and applyHeaderVisibilitySettings() toggles the marker class after settings load. The button toggles the file-browser panel open/closed without opening the settings modal (panels-ui.js). The Cron toolbar button joined the same opt-in pattern in 1.6.0: template ships btn-cron--hidden, applyHeaderVisibilitySettings() toggles it via the per-device showCronButton setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.

Gesture control (the camera hand-tracking overlay) is opt-in, default OFF, under App Settings → Display → Input (gestureControlEnabled). CODEMAN_GESTURE=1 makes the feature available on the instance (CSP widening + /gesture/ assets) and sets window.__codemanGestureAvailable (the Input section only shows when set); the overlay bundle is injected by renderIndexHtml only when the setting is enabled, so that method is async and reads settings.json via readSettings(true) — the true forces a fresh read (bypassing the 2s _settingsCache), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).

Gesture-control source lives in-repo at packages/gesture-control/ (workspace package codeman-gesture-control, was the standalone Ark0N/codeman-gesture-control repo). The transport-agnostic core is src/gesture/* (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); src/codeman/entry.ts is the Codeman consumer that maps grab/drag/drop onto real .session-tab/toolbar buttons and is the bundle entry. Edit there, then run npm run build:gesture (scripts/build-gesture-bundle.mjs → esbuild bundles entry.ts, MediaPipe JS included, into src/web/public/gesture/gesture-codeman.js) and commit the regenerated bundle — the committed bundle is what dev/tsx serves (no bundler at runtime), and scripts/build.mjs reruns the same step so prod always reflects current source. The MediaPipe wasm + model are NOT bundled — loaded at runtime from same-origin /gesture/wasm + /gesture/gesture_recognizer.task, fetched by scripts/fetch-gesture-assets.mjs (gitignored, see Gotchas). entry.ts mounts window.__codemanGesture = new GestureBridge() idempotently at module-eval. A standalone vite playground (npm run dev in the package — fake tabs, no Codeman) lets you iterate on gesture feel in isolation. ⚠️ Keep MP_VERSION in fetch-gesture-assets.mjs in sync with @mediapipe/tasks-vision in packages/gesture-control/package.json.

Theme skins (App Settings → Display): the skin setting selects a palette via a data-skin attribute on <html>. Values: daylight-blue (default), daylight-green, og (OG Codeman). CSS lives under [data-skin="…"] blocks in styles.css. To avoid a flash-of-wrong-theme, an inline pre-paint script in index.html (<head>) reads localStorage['codeman:skin'] and sets data-skin before first paint; settings-ui.js applySkin() applies it live on save (sets html[data-skin] + window.__codemanSkin, syncs the standalone codeman:skin key with the settings blob, and calls terminal-ui.js applyTerminalSkin() to re-theme live terminals). skin is a per-device/client-only setting — it's destructured OUT of the server payload (settings-ui.js, alongside localEchoEnabled/cjkInputEnabled/extendedKeyboardBar), so it does NOT sync across devices.

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+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above).

Security

Full model: docs/security-architecture.md — network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, and recommended secure setups.

Layer Details
Auth Optional HTTP Basic via CODEMAN_USERNAME (defaults to admin) / CODEMAN_PASSWORD env vars. Active only when CODEMAN_PASSWORD is set (middleware/auth.ts)
Network bind Defaults to 127.0.0.1 (loopback). A non-loopback bind (--host/CODEMAN_HOST) without CODEMAN_PASSWORD starts but warns loudly (0.9.0; was fail-closed in COD-29/#107). --allow-unauthenticated-network / CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 acknowledges the warning. Classifier: network-auth-policy.ts
Host guard Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, *.ts.net/*.trycloudflare.com/*.cfargotunnel.com, the active managed tunnel, and CODEMAN_ALLOWED_HOSTS. ⚠️ Custom reverse-proxy domains are rejected unless added via CODEMAN_ALLOWED_HOSTS=host,.suffix. registerHostGuard in server.ts; policy in network-auth-policy.ts (buildHostPolicy/isAllowedRequestHost/isAllowedRequestOrigin)
CSRF / Origin Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). A missing Origin is allowed so curl/CLI and Claude Code hooks keep working. The global body parser keeps text/plain RAW (no auto-JSON-parse, which had enabled simple-request CSRF); /api/crash-diag self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in ws-routes.ts. Added in c669518 (closes 2026-06-09 review CRITICALs)
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 has separate limiter
Hook bypass /api/hook-event (and /api/status-telemetry, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (CODEMAN_PASSWORD set), the loopback bypass requires the per-instance X-Codeman-Hook-Secret header unconditionally — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/tailscale serve/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via $CODEMAN_HOOK_SECRET_FILE (session env, config/hook-secret.ts); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable refuses without CODEMAN_PASSWORD unless exposure is acknowledged — via CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 (env, COD-55) or the per-request acknowledgeUnauthTunnel:true action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted
Env vars CODEMAN_MUX (managed session), CODEMAN_API_URL (auto-set for hooks), CODEMAN_ALLOWED_HOSTS (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare .suffix matches subdomains), CODEMAN_DOCKER_BRIDGE_HOOKS=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from CODEMAN_DOCKER_BRIDGE_HOST or auto-detect)
Validation Zod schemas, path allowlist regex, env prefix allowlist (CLAUDE_CODE_*/OPENCODE_*/CODEX_*/GEMINI_*/GOOGLE_*)
Headers CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS

SSE Event Registry

~146 event types in src/web/sse-events.ts (backend) and SSE_EVENTS in constants.js (frontend), incl. docker:exportComplete/exportFailed/importComplete and docker:imageBuildStarted/imageBuildProgress/imageBuildComplete/imageBuildFailed. Both must be kept in sync.

API Routes

~188 handlers across 20 route files in src/web/routes/: system (45, incl. self-update check/status/POST /api/system/update, POST /api/system/span-displays → spawns scripts/span-codeman.sh, GET /api/codex/status, GET /api/gemini/status, and GET /api/away-digest), sessions (32, incl. GET /api/sessions/unified, POST /api/sessions/:id/pin, PUT /api/session-order), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + docker-link + docker-quickcreate + export/import + docker-exports), ralph (9), plan (8), files (14, incl. attachment register + list/history + :attachmentId/raw/preview/thumbnail + workspace file-preview/file-thumbnail), respawn (7), admin (6, multi-user /api/admin/users*), mux (5), push (4), scheduled (4, legacy ScheduledRun), cron (9, cron-style CronJob jobs/runs), teams (2), me (2, /api/me + password), search (1, GET /api/search), hooks (1), clipboard (1), status-telemetry (1, POST /api/status-telemetry ← statusLine exporter), 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 in src/web/routes/*-routes.ts. Return the ApiResponse envelope ({ success: true, data }; errors via createErrorResponse() with proper status code). Validate with Zod schemas in schemas.ts.
  • SSE event: Add to src/web/sse-events.ts + SSE_EVENTS in constants.js, emit via broadcast(), handle in app.js (addListener()
  • Session setting: Add to SessionState, include in session.toState(), call persistSessionState()
  • Hook event: Add to HookEventType, add hook in hooks-config.ts:generateHooksConfig(), update HookEventSchema
  • Mobile feature: Add to relevant singleton, guard with MobileDetection.isMobile()
  • New test: Pick unique port (search const PORT =). Route tests use app.inject() (no port needed) — see test/routes/_route-test-utils.ts.

Validation: Zod v4 (different API from v3). Define schemas in schemas.ts, use .parse()/.safeParse().

State Files

All in ~/.codeman/: state.json (sessions, settings, respawn, orchestrator, cronJobs/cronJobRuns), mux-sessions.json (tmux recovery), settings.json (user prefs), push-keys.json (VAPID), push-subscriptions.json, session-lifecycle.jsonl (audit log), update-status.json (self-updater progress, polled across the service restart), linked-cases.json (linked-case registry used for case-path resolution), remote-hosts.json + remote-cases.json (remote SSH hosts/cases, COD-94), docker-hosts.json + docker-cases.json (docker hosts/cases, 1.4.0) + docker-exports/ (portable container bundles), subagent-window-states.json + subagent-parents.json (subagent window layout, GET/PUT /api/subagent-window-states/-parents), hook-secret (per-instance hook secret, COD-54), users.json (multi-user accounts, scrypt hashes, mode 0600) + admin-audit.jsonl (multi-user admin action log), 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

Never run the bare full suite (npm test with no file argument): the default config includes the browser-driven suites (test/mobile/** and 3 other Playwright tests), which need a live server + chromium + environment-specific PNG baselines and will fail/hang locally. Run individual files, or test:ci for a broad sweep:

npm test -- test/<specific-file>.test.ts         # Single file (SAFE, uses config/vitest.config.ts)
npm test -- -t "pattern"                          # By name (SAFE)
npm run test:ci                                   # Everything except browser/perf suites — what CI runs
# npm test                                        # DON'T — includes browser/visual suites

Raw npx vitest skips config/vitest.config.ts; always use npm test -- or pass --config config/vitest.config.ts.

Config: Vitest with globals: true, fileParallelism: false. Timeout 30s, teardown 60s. config/vitest.ci.config.ts = same minus the browser/perf excludes — keep the two configs in sync when changing shared options.

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). test/setup.ts 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).

Ports: Pick unique ports manually. Search const PORT = before adding new tests.

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/ (135 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 in src/config/: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Terminal history (src/config/terminal-history.ts, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env CODEMAN_MAX_TERMINAL_BUFFER/CODEMAN_TRIM_TERMINAL_TO; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable BufferAccumulator trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (DEFAULT_SCROLLBACK in constants.js — 100k/tab is a mobile-memory hazard). Settings keys terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes are schema-validated but inert (only tmuxHistoryLimit is wired live); buffer-limits.ts re-exports the defaults. Text/message limits are env-overridable too (CODEMAN_MAX_TEXT_OUTPUT/CODEMAN_TRIM_TEXT_TO/CODEMAN_MAX_MESSAGES). Image upload (image-input.js / config/buffer-limits.ts): up to _maxBatchImages 20 images/batch (bounded concurrency 3), per-file MAX_PASTE_IMAGE_BYTES 50MB (env CODEMAN_MAX_PASTE_IMAGE_BYTES); the mobile camera-roll picker auto-downscales to fit before upload. HEIC paste uploads (#151): converted server-side to JPEG in a worker_threads worker (web/heic-jpeg-worker.ts, resourceLimits + 30s timeout) gated by runWithConversionLimit(); detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: heic-decode + jpeg-js. Use LRUMap for bounded caches, StaleExpirationMap for TTL cleanup. Anti-flicker pipeline: 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*(). Verify: npm test -- test/memory-leak-prevention.test.ts.

Scripts & Tunnel

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). Production services: scripts/codeman-web.service, scripts/codeman-tunnel.service. Always set CODEMAN_PASSWORD before exposing via tunnel.