On this page

The two session records

Commander keeps two records per conversation; conflating them causes most of the confusion.

RecordLives inIdentityWhat it holds
Commander sessioncommander.sessionsCommander UUID (:id)title, env_name, status, pinned, model_override, provider_override, account_id, resolved_account_id, claude_session_id, archived / archived_at / dismissed_at, scroll_buffer, cc_config
Claude Code transcripta .jsonl file under the account's config rootClaude conversation UUID (:sid)the conversation itself: prompts, turns, tool uses, message.usage

claude_session_id is the link (FEAT-018). Commander mints a v4 UUID at a fresh launch and passes it as --session-id, so the conversation's transcript is known before it exists. PATCH /api/sessions/:id with claude_session_id persists it; the column is uuid, so a malformed value is rejected by PostgreSQL. POST /api/admin/reconcile-transcript-links (?dryRun=1 reports only) clears links whose transcript no longer exists, and refuses to run when a transcript root is unreadable or accounts enumerate empty — clearing a good link is not reversible from the database side.

Lifecycle

StepRouteContract
ListGET /api/projects/:pid/sessionsrows with is_live, plus chat_title and account_label where they resolve; ?archived=true widens the query and is gated on session-rejoin. Decoration runs on a time budget, so a cold process answers without it rather than late
CreatePOST /api/projects/:pid/sessionstitle, env_name (default production), context_file, model_override, provider_override, account_id when accounts exist. Creating a row starts no process — a WebSocket does
Read backGET /api/sessions/:id/restorethe row plus its persisted scroll_buffer and chat messages, for display outside the terminal
Restart the CLIPOST /api/sessions/:id/restart-cckills the live PTY so the rejoin path re-runs claude --resume; 409 when nothing is live, and it never spawns one
Archive / dismissPATCH /api/sessions/:id/archive{archived: true}, plus {dismissed: true} to hide the row without deleting. Archiving is ungated (the normal close flow); reactivating is gated on session-rejoin
Trash / restore / purgePOST /api/claude-sessions/:sid/trash, …/restore, DELETE /api/claude-sessions/:sidtrash moves the transcript and its subagent directory to a trash tree with a purgeAt deadline; a file written inside the last five minutes answers 409 code: "ACTIVE" unless {force: true}. Purge is irreversible and only works from trash
Cold archivePOST /api/claude-sessions/:sid/unarchivebrings one rolled-out transcript back to its live location
Delete the rowDELETE /api/sessions/:idkills the process tree, revokes MCP tokens minted for the session (RFC 7009), removes the session's .mcp.json, .cc-settings.json and .cc-agents.json, deletes the row. The transcript is untouched

Parking sits between archive and trash: a periodic sweep flushes every live PTY's scrollback and kills the PTY of a session archived and idle past the configured grace. Transcript and owned id survive, so reopening resumes the same conversation — parking reclaims resources, it deletes nothing.

Resume versus attach (FEAT-048)

Claude Code ≥ 2.1.261 can hold a session in a background daemon worker. While it does, claude --resume <id> is refused by the CLI; the way in is claude attach <short>. Commander reads each config root's daemon roster and confirms liveness from the worker process, so a recycled pid cannot paint a phantom. GET /api/claude-sessions decorates a listed session with daemon: { short, live, cwd, cliVersion, root } when a live worker holds it — the signal that resume is the wrong path for that session.

GET /api/claude-sessions/:sid/exists answers { exists, complete }. A hit is definitive; a miss is only trustworthy when complete is true — false means an account root had not been searched yet, so the reading is "unknown", not "gone".

The PTY-over-WebSocket contract (RFC 6455)

One socket per tab, wss://<instance>:<wsPort>/ws?session=<uuid>&project=<uuid>. The port is served in GET /api/client-config; nothing hardcodes it. Many sockets may subscribe to one process.

Server frames, all JSON (RFC 8259):

typePayloadMeaning
connectedsessionId, project, reconnected, cols, rows, replayattached; replay is a serialized current screen, not raw byte history
output / exitdata / codePTY output; process exit
session_deadsessionStatus, scrollbackthe session ran before and has no process; nothing spawns until a revive frame asks
modelsessionId, model fields, scope: "session"the acting model changed, read from the transcript tail
cc-credit-prompt / cc-watchsessionId, and for a watch watch + extracta registered output watch fired once for this process (the credit prompt keeps its own type for older clients)
errormessagethe socket is refused, or the terminal could not be established

Client frames: input (data string), resize (cols, rows, clamped server-side), and revive (only meaningful after session_dead).

Refusals, all fail-closed: no or invalid session token; an embedded-scope token (embedded consoles have no terminal access); missing session/project; non-UUID ids; unknown project or session; **a session whose project_id does not match the project it claims**; a missing node-pty build. Oversize frames close the socket (RFC 6455 §7.4.1, 1009); a subscriber past the backpressure cap is terminated and resyncs from the snapshot; ping/pong keepalive terminates half-open peers; malformed frames are dropped and logged once per socket.

GET /api/claude-sessions is the index: primaries with id, title, summary, cwd, gitBranch, model, userPrompts, assistantMsgs, toolUses, lines, firstTs/lastTs, sizeBytes, active, lastApiError, plus trash, retentionDays, and count-only summaries of what segmentation hides (excluded, archive?includeExcluded=1 / ?includeArchive=1 load them). GET /api/claude-sessions/:sid/prompts returns that conversation's messages (gated on transcript-search or session-manager); POST /api/claude-sessions/:sid/brief distills a transcript into a markdown handoff file, the alternative to resuming a context-heavy conversation; and GET /api/transcripts/export?population=<project id|workspace path|encoded dir>&scope=hot|archive|both streams a zip with a manifest.

GET /api/transcripts/stats classifies every file by kind: primary (a UUID-named transcript), subagent (an agent-<hex>.jsonl, whose parent link is the first record's sessionId), and unknown — a first-class outcome, never coerced. It reports orphans, parents, fan-out median/p90/max and unknownPct; unknownPct above the alert threshold is the early signal that the CLI's layout changed and classification is behind.

Flood alerts and population exclusion

Batch-shaped work (one session per entity) grows the corpus without bound. Each interactive sweep counts recent files per population and publishes GET /api/transcripts/flood-status → `{ alerts: [{ dir, recent, total, windowHours, threshold, at, project? }], threshold, windowHours }`. Alerts are advisory: hiding data is an operator decision, taken by setting a population's scan policy (excluded, maxAgeDays, hotMax). Excluded populations are skipped before any stat or parse — keeping interactive cost independent of batch corpus size — and stay readable on demand.

What requires a live process

restart-cc needs one (409 otherwise), as do input and resize frames. Usage, transcript reads, briefs, archive flags, trash and export do not — they read the row and the file. GET /api/sessions/:id/usage answers { available: false, reason } (session_not_linked, transcript_not_found, transcript_unreadable) rather than guessing.

commander-personas (what a launch carries), commander-observability (activity, flood and usage signals), commander-security (why a socket or route refused).

You've seen the proof

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

Get your quote →