On this page

Developing Claude Commander

This page is for whoever changes the code. Commander is deliberately small in shape and strict in rules: one Express server (server.js, ~7.9k lines), a lib/ of pure or narrowly-scoped modules, one single-file React SPA (public/index.html), a numbered migration set, and a test suite that runs on node:test with no framework. The rules that keep it that way are in CLAUDE.md (the Engineering Constitution) and docs/governance/CMD-DEV-STD-001.md (the development-governance standard). Read both before your first change; this page shows how to satisfy them in practice.

Tutorial: the local development loop

Commander runs two instances on one host, and they are not interchangeable.

DevProd
Worktreecommander-devclaude-commander
HTTP / WebSocket port3210 / 32113200 / 3201
pm2 processcommander-devcommander
Databaseclaude_commander_devclaude_commander

Never test against prod. Prod hosts live terminal sessions belonging to the operator; a restart there kills them and a bad write there has no undo. Every experiment, migration and live-drive belongs on dev.

  1. Orient before you edit. docs/sprints/next-task.txt is the pointer-of-record: read it at the start of a session to learn what is in flight, what is blocked and on whom. Feature specs live in tracking/features/FEAT-NNN.md, shortcuts and their remediation plans in tracking/, releases in tracking/releases/. If the pointer, the code, the database and the pm2 runtime disagree about something, that disagreement is the defect: stop and surface it rather than picking a winner.
  1. Work in the dev worktree. Both worktrees are checkouts of the same repository; ecosystem.config.js describes the prod process and a sibling ecosystem.dev.config.js (in the dev worktree) describes the dev one. Secrets for both live in a secrets.env file at mode 0600 outside every worktree, loaded by the process config — never in a tracked file.
  1. Know which change needs what. This distinction decides whether a change needs a restart at all:
You changedTo take effect
public/index.html, public/vendor/*, other static assetsbrowser reload only — the file is served statically
server.js, lib/*.js, migrations/*.sqla dev process restart (and for SQL, node migrate.js first)
  1. Do not restart commander-dev while the operator is working on it. A pm2 restart drops every live WebSocket (RFC 6455) terminal on that instance, taking the operator's session with it. When people are connected, stage the change and verify it statically instead: transpile-verify the SPA block, run the suite, read the diff. Restart only in an agreed window. When you do restart dev, use pm2 restart commander-dev and never --update-env — pm2 stamps the invoking shell's environment into its saved state, and the 2026-08-16 env-clobber incident is exactly that. Recovery from a clobber is pm2 delete commander-dev && pm2 start ecosystem.dev.config.js.
  1. Verify the SPA without a browser. public/index.html is transpiled in the browser by the vendored Babel standalone build, so a JSX syntax error is a blank page rather than a build failure. Before you hand the file over, extract the <script type="text/babel"> block and transpile it with Babel under Node (stubbing window/React as UMD requires); a clean transpile is the gate named in CMD-DEV-STD-001 §3.4.
  1. Live-drive when the flow changed. The committed verify skill (.claude/skills/verify/SKILL.md) drives headless Chromium against the dev instance with WS-frame capture and screenshots. Two constraints it records: the terminal socket is TLS-only on 3211, so drive the UI over the HTTPS hostname rather than http://localhost:3210 (an http: page derives ws: and the handshake dies); and a driver mints its own short-lived row in commander.auth_sessions because the password is argon2id-hashed (RFC 9106) and not recoverable.
  1. Run the suite and record the work. npm test before every commit (see Run the suite), then the governance artefacts: an Impact Record at work/impact/<ITEM-ID>.json and, after a set of related tasks, a vet record at work/vet/<SET-ID>.md.
  1. Commit the slice on dev. The promotion ladder's verify rung refuses a dirty tree, so finished slices are committed as you go. Promotion to prod is not yours: see Ladder Promote is operator-hand in best practices.

How-to

Add a setting

A setting is an operator-editable value that does not need live-reload semantics. The chain has one direction and three links: environment variable → commander.settings row → /api/client-config (that last link only for values the browser needs).

  1. Declare the named default as a const at the top of server.js, sourced from the environment: const FOO_MS = parseInt(process.env.FOO_MS, 10) || 5000;. This literal is the only one allowed — a literal at the point of use is a constitution violation.
  2. Read the settings override through getSettings(), which caches the whole commander.settings table for 30 seconds: parseInt(s.foo_ms, 10) || FOO_MS.
  3. If the browser needs it, add it to the object served by GET /api/client-config (server.js ~line 696) and consume it in applyClientConfig in the SPA. The client-side literal you leave behind is a stale-server fallback only, never a point-of-use value.
  4. If the value is credential-bearing, add the key to SETTINGS_ENCRYPTED_KEYS in lib/settings-secrets.js (AES-256-GCM at rest, NIST SP 800-38D) or to SETTINGS_MASKED_KEYS in server.js if it merely must never be served back. Both paths make GET /api/settings serve the mask ********, and PATCH treats an echoed mask as "unchanged" — a contract you must not break, because the 2026-08-05 dev lockout was a masked key that served blank and was then saved back as empty.
  5. If the key changes who can log in, it is already guarded by prefix or floor in lib/auth-guard.js — and it is not yours to change (see best practices).

Add a live tunable

A tunable is a setting the operator can change and see take effect without a restart. Tunables are data, not code: one registry, one resolver, lib/tunables.js.

  1. Add one entry to REGISTRY in lib/tunables.js: key (the settings row), env (the environment variable), type (int, float, bool, color, csv), the named default, optional min/max/unit, a group from GROUPS, an operator-facing label and help, and live: true when a save applies without a restart. Nothing else changes — no branch, no new route, no new UI field.
  2. Read it at use time, never at boot. In server.js the helper is T('your_key') (line ~922), a synchronous read over the settings cache that kicks a background refresh when stale and serves the last known value meanwhile. In lib/ modules, take the settings map from the caller and call tunables.value(key, settings); tunables.envDefault(key) gives the env→default resolution for a module with no settings map.
  3. For a periodic job, use scheduleLoop(name, fn, intervalKey, startupDelayMs, enabledKey) (line ~930) rather than setInterval. It re-reads the interval (and the optional enable switch) from the registry on every tick, so a saved change applies at the next tick; setInterval freezes its period at boot. Every timer is unref'd — the HTTP listeners keep the process alive, never a timer.
  4. Validation is automatic in both directions. PATCH /api/settings runs tunables.parse() on any registry key and returns 400 with the reason on a bad value; a blank value deletes the override and restores the env/default. On read, an invalid stored value warns once and falls through to the next rung rather than being honoured silently.
  5. The UI needs nothing. GET /api/tunables (authenticated) returns tunables.describe() — every descriptor plus its effective value and its source (settings, env or default) — and Settings ▸ Advanced renders that payload. v3.0.15 carries 29 tunables in 7 groups.

Restart-only values stay environment-only and are catalogued in tracking/features/FEAT-055.md; do not put one in the registry and imply it is live.

Add a migration

  1. Create migrations/NNN_short_name.sql, numbered after the current highest (036 at this commit). Additive-first: add columns and tables, backfill, and leave the old shape readable by a stale server.
  2. Apply it with node migrate.js only. DATABASE_URL is required and has no default — the runner refuses to guess a database. Each file runs in its own transaction and is recorded inside that transaction in commander.schema_migrations (filename, SHA-256 checksum per FIPS 180-4, applied_at).
  3. Never hand-apply SQL, on any instance. Applied files are skipped by ledger lookup, and a file that changed after it was applied is a hard error (MIGRATE_ALLOW_CHECKSUM_DRIFT=1 overrides it only after you have reviewed the diff). A ledger that disagrees with the directory halts the migrate rung on the next promotion, and reconciling it is manual work.
  4. The one sanctioned in-code DDL is the ledger table's own CREATE SCHEMA … CREATE TABLE IF NOT EXISTS commander.schema_migrations, which lives in migrate.js so a fresh database can bootstrap. Any other schema-bootstrapping code in the server is an out-of-band schema change and a red gate cell.
  5. A pre-ledger database (tables present, ledger empty) refuses to run until you record the current files once with node migrate.js --baseline, so half-applied guessing cannot happen.

Add an API route

Every route handler in server.js is wrapped at registration time. The loop at server.js ~598 rebinds app.get/post/put/patch/delete so each handler function is wrapped in a try plus Promise.resolve(...).catch(next). Express 4 does not catch a rejected promise from an async handler, and one escaped rejection kills the process — and every live PTY with it. Because of the wrapper you write plain async (req, res) => … and let a throw land in the JSON error middleware at the bottom of the file, which maps Postgres 22xxx/23xxx codes to 400 Invalid request and everything else to 500 Internal server error (RFC 8259 bodies, no stack leaked to the client).

Compose the guards in this order:

GuardUse it whenBehaviour
authCheckalways, unless the route is deliberately public (/api/auth/check, POST /api/auth, /api/client-config)reads the commander_token cookie or x-auth-token header against commander.auth_sessions; 401 on failure. Named directly on 164 route registrations.
validateUUIDthe path has :id, :pid, :sid, :projectId or :sessionIdrejects a non-UUID with 400 before any query runs; 86 route registrations
requireFeature('key')the route is edition-gatedresolves entitlements and answers 403 with {feature, tier, upgrade}; an unknown feature key throws at boot, not at request time; 37 gated routes
confine(path, roots, kind) from lib/fs-confine.jsthe handler touches the filesystem with any operator-supplied pathrealpaths the target (or its nearest existing ancestor for 'write') before the prefix check, with a path.sep boundary guard, and returns null when out of bounds. Deny by default: no roots means nothing resolves.

Two rules that are not optional. The server is the enforcement — a UI gate is an advertisement, and a route shared with an ungated surface must re-check server-side. And never fail silent: an unparseable config warns and falls back to a named default, a refused write says which key and why.

Add a frontend component

The SPA is one file: public/index.html, ~9.3k lines, a <script type="text/babel"> block transpiled in the browser by public/vendor/babel-standalone-7.26.4.min.js, rendering React 18 from public/vendor/react-18.production.min.js into #root via ReactDOM.createRoot. Every runtime dependency is vendored with its version pinned in the filename — React, ReactDOM, Babel, xterm 5.3.0 and its fit and web-links addons. Do not add a CDN reference. ISSUE-001 was exactly that: CDN-loaded libraries with no fallback, so a CDN hiccup was a blank page.

Working rules:

Write a test

The suite has three layers, all under test/, all on node:test with node:assert and no framework or mocking library.

Write the assertion message, not just the assertion — every existing test names what the failure means, which is what makes a red suite readable by whoever did not write the code.

Run the suite

npm test                      # node --test test/*.test.js
COMMANDER_TEST_PG=<admin-url> npm test   # same, with Layer 2 actually running

As this page was written: 43 test files; a plain npm test reports 346 passing tests, with the Layer 2 suite reported as a single SKIP. A suite that silently skipped Layer 2 is not a green gate. CMD-DEV-STD-001 §3.1 requires you to set COMMANDER_TEST_PG from the dev URL and confirm the TAP summary before calling the gate green; Layer 2 was dark from FEAT-024 to 2026-08, and that is why the confirmation is part of the rule rather than a suggestion.

Reference

Repository layout

PathHolds
server.jsthe single Express app: route registration, WebSocket and PTY wiring, the settings cache, the config chain's named defaults
lib/*.jsone concern per module, pure or dependency-injected so it unit-tests without the server (table below)
migrate.js, migrations/NNN_*.sqlthe migration runner and the numbered, checksummed migration set
public/index.htmlthe whole SPA; public/vendor/ its pinned, self-hosted runtime libraries
public/cc-capability.descriptor.json, public/personas.factory.jsonshipped descriptors the server reads
test/*.test.jsthe three test layers, node:test only
scripts/operator and build CLIs: vault, backups, key rotation, secret import, manifest signing, the docs and OpenAPI builders
deploy/Dockerfile, tenant compose, the version-manifest schema and example
docs/architecture, runbooks, handoffs, federation registry, governance/, and this documentation tree under docs/site/
tracking/feature specs, issues, bugs, releases, roadmap, and the shortcut/debt ledger
work/impact/ records and vet/ records — the governance evidence trail
.claude/the verify skill, hooks, and the operator quickstart

agent-launcher/ is a separate TypeScript package that has drifted from the served public/agent-launcher.js. It is a named standing violation of non-negotiable 3 being burned down, not a pattern to extend.

Generated reference, and the tools that generate it

Nothing in the machine-readable reference is maintained by hand:

CommandProduces
node scripts/route-inventory.mjs [--pretty]JSON [{method, path, file, line, auth, tag}] by parsing server.js and lib/*.js; dependency-free, handles multi-line route signatures
node scripts/build-openapi.mjsdocs/site/reference/openapi.yaml (OpenAPI 3.1.0) from that inventory merged with hand enrichments in openapi.overrides.json; the server URL is a template variable and the version comes from package.json, so the document is never pinned to an instance
node scripts/build-docs.mjsthe static documentation site and agent skill subtree, the site-generator content JSON, and the docs lint — secret-shaped literals and host home paths are errors, unresolved in-tree links and unbacked superlatives are warnings

If you add a route, regenerate the inventory rather than editing the OpenAPI document. If a route's middleware chain changes, the inventory's auth column changes with it, which is the point.

Module map: lib/

39 .js modules plus one generated .cjs. server.js owns HTTP shape and process wiring; these own the decisions.

ModuleConcern
agent-manifest.jsPublishes the signed agent-definition manifest (JWS) Commander serves at a well-known path (FEAT-026).
agent-sync.jsConsumes the curated official persona set from the Auth control plane (receive side, FEAT-024 P3a).
artifacts.jsExtracts agent-published asset references from transcripts; reference kinds are descriptor entries, not branches (FEAT-039).
auth-guard.jsThe login-seam step-up guard: which settings keys are login-critical and what 422/428/403 mean (FEAT-044).
auth-oidc.jsGoverned-login adapter — Commander as a relying party of DataShield Auth (FEAT-028 A).
auth.jsLocal identity: argon2id hashing (RFC 9106) and opaque DB-backed sessions (FEAT-024 P0).
blueprint-intake.jsPOST /api/blueprint/intake — server-side structural and signature validation of a signed AppDefinition.
blueprint-validate.cjsGenerated schema validator. Do not hand-edit; regenerate from the Blueprint repo.
blueprint-wizard.jsHosts the Blueprint wizard inside Commander behind authCheck.
cc-agents.jsClaude Code sub-agent fleet: catalogue discovery, per-persona bindings, the --agents launch payload (FEAT-022).
cc-capability.jsReads public/cc-capability.descriptor.json, the single source for the Claude Code capability surface.
cc-config.jsMerges scoped Claude Code config layers against the capability descriptor (FEAT-022).
cc-daemon.jsReads Claude Code daemon rosters to detect live background sessions (FEAT-048).
cc-trust.jsPre-trusts Commander-launched project directories so no session stalls on the trust dialog.
cc-version.jsDetects the installed Claude Code version, which drives min_version gating.
cc-watch.jsOutput-watch registry: generalises one hardwired prompt-pattern set into registered patterns (FEAT-047 P3).
claude-bin.jsResolves the Claude CLI executable across user-scoped npm prefixes.
claude-sessions.jsTranscript manager: scans the transcript corpus, extracts metadata, owns the soft-delete lifecycle, and holds MODEL_REGISTRY and the live /v1/models merge.
cpu-util.jsPure CPU-utilisation maths over two /proc/stat snapshots.
crypto.jsAES-256-GCM helpers (NIST SP 800-38D), Node built-in crypto only.
deploy-manifest.jsSigns the deployment version-lock manifest validated against its JSON Schema 2020-12 document (FEAT-030).
embedded-console.jsPer-project embedded developer console, mounted before every /api route so the scope binds the whole surface.
entitlements.jsThe single licence→capability resolver; feature keys, tier maps, overrides, requireFeature (FEAT-024 P2).
fs-confine.jsFilesystem root-allowlist confinement with realpath-before-check and boundary-aware containment.
integration-providers.jsDescriptor registry for External Integrations: field vocabulary, storage, health probe, setup guidance (FEAT-051).
lighthouse.jsServer-side client of the licence authority; contract-first (FEAT-027 D2).
mcp-broker.jsConsumer-side client of the Auth Application Binder — Commander is not the credential authority (FEAT-016).
mcp-routes.jsThe MCP consumer HTTP surface; owns shape only, delegating credential work to the broker.
oauth-token.jsParses and writes long-lived claude setup-token credentials.
personas.jsPersona registry: DB-authoritative with a code factory reset from public/personas.factory.json (FEAT-015).
promote-autoclean.jsPure decision function for verify-time autoclean of known-generated files.
promote-override.jsPure decision function for the audited operator override of a dirty-tree verify halt (FEAT-046).
settings-secrets.jsEncryption-at-rest and mask semantics for credential-bearing commander.settings rows.
skills.jsSkill-pack catalogue and filesystem discovery for persona bindings (FEAT-015 3d).
systemd-service.jssystemd awareness for the process monitor, with a validated sudo argument list (FEAT-049).
tracking-markdown.jsMarkdown + YAML-frontmatter engine for the tracking/ files.
tunables.jsThe operator-tunable registry and its resolver: one descriptor list, one resolution order (FEAT-055).
usage-stats.jsUsage and cost from transcript ground truth, per model and per session (FEAT-025 M1).
vault.jsPassword Vault: one row shape, one encryption, one set of SQL, shared by the routes and the CLI (FEAT-056).
ws-hardening.jsPure WS/PTY hardening helpers — origin checks, dimension clamps — unit-testable without sockets.

Status-code conventions

The SPA and the agent skills both branch on these, so a new route uses the same vocabulary.

StatusMeaning hereSet by
400malformed input: bad UUID, bad commit hash, a tunable value outside its bounds, a Postgres 22xxx/23xxx codevalidateUUID, tunables.parse, the error middleware
401no session, or a session that no longer resolvesauthCheck
403path outside the allowed filesystem roots (with the roots hint), a credential-shaped filename refused, a feature not in the plan, or a wrong step-up proofrefuseOutsideRoots, requireFeature, lib/auth-guard.js
409the request is coherent but the resource is not in a state that allows it (for example restarting the CLI in a session with no live process)the handler
415the file exists inside a root but is not a type the surface will servethe handler
422the write itself is incoherent — notably a login-seam change that would leave the instance with no working login pathlib/auth-guard.js
428step-up required: re-send with x-current-password (RFC 6585, NIST SP 800-63B §5)lib/auth-guard.js
500unhandled — logged with a stack server-side, opaque to the clientthe error middleware

Test layers

LayerWhereNeedsAsserts
1 — unittest/*.test.js over lib/nothingpure decisions over fixtures; no DB, no network
2 — API contracttest/api.test.jsCOMMANDER_TEST_PG (admin URL with CREATEDB)the real server on a disposable DB: status codes, auth gate, masking, confinement
3 — render smoketest/api.test.jssame as Layer 2GET / serves real HTML with the Babel block and app content (ISSUE-001 guard)

CMD-DEV-STD-001 in one table

docs/governance/CMD-DEV-STD-001.md v1.1, PROPOSED for operator lock; binding as working practice from drafting. It inherits the fleet development-governance instruments by reference and does not restate CLAUDE.md.

RuleWhat it requiresWhere it lands
R1 — evidenceevery work item commits an Impact Record; docs-only items includedwork/impact/<ITEM-ID>.json
R2 — verdict gatingno successor starts while a predecessor's verdict is amend/reject/pending on the same trackdelivery note + record
R3 — post-set revetafter each related set, an unprompted re-vet of ≥10 angles against the actual diff and the previous 3 series; every finding dispositioned, never droppedwork/vet/<SET-ID>.md
§3 gatessuite green with Layer 2 confirmed; config purity; migration-only schema change; SPA transpile-verify; a rotten gate is surfaced, never worked arounddelivery note states which reload/restart was needed and done
§5A model floorsubagents run Opus (analysis/code) or Sonnet/Haiku (mass/mechanical) with model set explicitly — never Fable/Mythos, never inheritedbackstopped by .claude/hooks/block-fable-subagent.sh (PreToolUse, exit 2 on an explicit Fable/Mythos spawn; it cannot see an inherited or in-script model)
§5 operator-handladder Promote to prod, prod restarts and DB writes, destructive migrations, secrets rotation, protected infrastructure, and the login seam (app_password, auth_mode, auth_oidc_*, mcp_auth_base_url, lib/auth-guard.js, lib/auth*.js)left PENDING; agents never approve their own work
§4 artefactsfeature specs in tracking/features/, releases in tracking/releases/, shortcuts recorded in tracking/ before they land, seam changes update docs/federation/INTEGRATION-REGISTRY.md in the same committhe repository, diffable

Explanation

The five non-negotiables, and what each one is protecting

CLAUDE.md states five rules and asks you to treat a violation as a failing build.

1 — No hardcoded values. Every tunable routes environment variable → commander.settings/api/client-config; a literal is legal only as the declared, named default at the top of that chain. The reason is not tidiness: a value settable in three places with no defined order is three bugs waiting, and the same build has to serve a personal host, a team instance and a multi-tenant container without a code change.

2 — No technical debt. No "temporary" hack, no TODO-later workaround, no out-of-band schema change; every schema change ships as a migration. Where a shortcut is genuinely unavoidable it is recorded in tracking/ with a remediation plan before it lands. Debt that is written down gets burned down; debt that is only remembered becomes the next incident, which is why the constitution keeps a short list of standing violations by name instead of a vague intention.

3 — No code sprawl. Prefer deleting and consolidating over adding. One renderer, one registry, one source of truth per concern; duplicated logic across server.js, index.html and agent-launcher.js is a defect even when it works. The precedent is the triplicated model list, retired by FEAT-019 down to one live source (/api/models with the MODEL_REGISTRY fallback) consumed through one hook.

4 — Hardened, soft-coded, configuration-based, with clean seams. Descriptor, registry and adapter patterns, designed against 2026–2030 expectations. The server is authoritative; clients render from served config.

5 — Standards first. When a design leans on a standard, cite the document. In this codebase that is load-bearing rather than decorative: WebSocket framing and close codes follow RFC 6455 (an oversize frame closes with 1009; ping/pong keepalive per §5.5.2–.3), JSON bodies RFC 8259, step-up signalling RFC 6585 428 with NIST SP 800-63B §5 as the reauthentication model, password hashing RFC 9106 argon2id, secrets at rest AES-256-GCM per NIST SP 800-38D, migration checksums SHA-256 per FIPS 180-4, manifests JSON Schema 2020-12, versions SemVer 2.0, timestamps ISO 8601. A cited standard is a decision you do not have to re-argue, and a reviewer can check it without reading your mind.

Descriptor, registry, adapter

The pattern is the same everywhere and worth naming, because it is how a change becomes data instead of a branch.

The seam this is all aimed at is FEAT-013, the provider descriptor registry that would make agent CLIs beyond Claude Code data rather than code branches. It is status: proposed at this version — not shipped — which is exactly why new Anthropic-specific code should route through a descriptor now rather than add another branch to unpick later.

Why one file for the SPA, and pure modules behind it

These two look like opposite instincts and are the same one. public/index.html is a single file because the alternative here is not a nicer module graph — it is a build step, a bundler config, a lockfile for the front end, and a second place for versions to drift, on a project whose front end is served straight off disk and reloads without a restart. The cost is paid deliberately: the file is large, it is transpiled in the browser, and it has two independent nets (the error boundary and the boot watchdog) because a blank page was the failure mode that earned ISSUE-001 its number.

lib/ goes the other way for the same reason — to keep the thing that can be checked cheaply separate from the thing that cannot. Decisions are extracted into modules that take their inputs as arguments: ws-hardening decides without a socket, promote-autoclean and promote-override decide without a git repository, cpu-util without /proc, tunables without a database, fs-confine without a server. That is what makes Layer 1 the cheapest and largest layer of the suite, and it is why "add a branch in server.js" is usually the wrong shape for a change and "add a descriptor entry, or extract a pure decision" is usually the right one.

Why the server is authoritative

The browser is a rendering surface that any user can edit, replay or run at an old version. So the SPA hardcodes nothing it can be served, and every gate it draws is re-checked in the request handler: featOn() hides an affordance, requireFeature refuses the route. Two consequences follow. First, skew is a design input, not an accident — every server/client contract change states its stale-client and stale-server behaviour, which is why applyClientConfig guards each field and an absent entitlements map means allow-everything. Second, a UI-only capability and a server-only capability are both findings: the first is unenforced, the second is dark. Checking both across the five surfaces is a standing sweep lens, not an optional review nicety.

You've seen the proof

Ready for a number? Scope your deployment and we'll price it against your own economics.

Get your quote →