On this page
The two session records
Commander keeps two records per conversation; conflating them causes most of the confusion.
| Record | Lives in | Identity | What it holds |
|---|---|---|---|
| Commander session | commander.sessions | Commander 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 transcript | a .jsonl file under the account's config root | Claude 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
| Step | Route | Contract |
|---|---|---|
| List | GET /api/projects/:pid/sessions | rows 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 |
| Create | POST /api/projects/:pid/sessions | title, 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 back | GET /api/sessions/:id/restore | the row plus its persisted scroll_buffer and chat messages, for display outside the terminal |
| Restart the CLI | POST /api/sessions/:id/restart-cc | kills the live PTY so the rejoin path re-runs claude --resume; 409 when nothing is live, and it never spawns one |
| Archive / dismiss | PATCH /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 / purge | POST /api/claude-sessions/:sid/trash, …/restore, DELETE /api/claude-sessions/:sid | trash 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 archive | POST /api/claude-sessions/:sid/unarchive | brings one rolled-out transcript back to its live location |
| Delete the row | DELETE /api/sessions/:id | kills 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):
type | Payload | Meaning |
|---|---|---|
connected | sessionId, project, reconnected, cols, rows, replay | attached; replay is a serialized current screen, not raw byte history |
output / exit | data / code | PTY output; process exit |
session_dead | sessionStatus, scrollback | the session ran before and has no process; nothing spawns until a revive frame asks |
model | sessionId, model fields, scope: "session" | the acting model changed, read from the transcript tail |
cc-credit-prompt / cc-watch | sessionId, and for a watch watch + extract | a registered output watch fired once for this process (the credit prompt keeps its own type for older clients) |
error | message | the 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.
Transcript index, kinds and search
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.
Related skills
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 →