Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Claude Code

← all harnesses · parser: crates/core/src/session/claude.rs

The harness cctop reads most deeply, and the only one whose transcript answers every column. Everything else on this page is a consequence of that: the context breakdown, the compaction chart and the subagent list exist because Claude Code writes enough down for them, not because they were designed provider-neutral and only implemented once.

Where it lives

$CLAUDE_CONFIG_DIR/projects/<project-slug>/<uuid>.jsonl, defaulting to ~/.claude (config::CLAUDE_CONFIG_DIR). A file is a session only if its stem is a full UUID — config::is_full_uuid in list_sessions — so a stray .jsonl in a project directory is ignored rather than half-parsed.

The project slug is not read for anything. The working directory a row shows comes from the cwd field inside the transcript (collect_static), because the slug is a lossy encoding of a path and the transcript states it outright.

Three things widen the search beyond that one directory:

  • Profiles. $CLAUDE_CONFIG_DIR lets one machine hold several logins, each with its own projects/. cctop reads all of them: any ~/.claude* directory holding a .credentials.json counts as an account (config::PROFILED), and config::profile_for stamps the PROFILE column from the transcript’s path. Reading only the directory the env var named was a real bug — a running session showed as a row with a process and no model, because its transcript was in a profile cctop was not looking at.
  • Other homes. $CCTOP_ALL_USERS and $CCTOP_HOMES add other users’ homes to every root; Session::owner is stamped from the path in list_all.
  • Claude for Mac. ~/Library/Application Support/Claude/ {claude-code,local-agent-mode}-sessions/<account>/<device>/local_*.json is metadata, not a transcript: cliSessionId points at a real Claude Code transcript nested under the session directory’s own .claude/projects, which find_desktop_jsonl locates. An entry flagged isArchived is skipped, and a vmProcessName marks the Cowork surface (the agent runs in a cloud VM, so there is no local process — see the liveness section). The label comes from userSelectedFolders[0], falling back to cwd.

Subagents get their own files: <transcript-stem>/subagents/<agent-id>.jsonl, with an optional <agent-id>.meta.json beside them. transcript_files in session/mod.rs is what makes a session mean “the main file plus its sidechains” everywhere it matters — including the mtime, since a subagent streams into its own file without touching the parent’s.

The format

JSONL, one record per line, type naming the kind: user, assistant, system, attachment, custom-title, ai-title. Discovery reads only the first 50 lines (collect_static) for the model, cwd, start time and launch id, and the last 200 (scan_custom_title) for a /rename, because a transcript can be hundreds of megabytes and the table needs neither.

A session whose first 50 lines name no model is dropped from the table entirely. That is an agent that started and never reached the API, and it has nothing to show.

Billable usage rides on assistant records as message.usage: input_tokens, cache_read_input_tokens, cache_creation_input_tokens, and a cache_creation object splitting the last into ephemeral_5m_input_tokens and ephemeral_1h_input_tokens. Older transcripts carry only the aggregate, so the remainder after the 1h tier is attributed to 5m, which is the default TTL.

Traps

Streaming repeats a request. The same requestId is written many times with growing counts. visit_assistant keeps only the last snapshot per key (last_by_key), and the totals are flushed once the file is read. Summing as you go inflates every figure in the session.

Parallel tool calls are not one record. The API shape suggests one assistant message with several tool_use blocks; Claude Code writes one record per block, all sharing a requestId. So counting blocks inside a record answers “1” for every call in a fan-out. attach_call_details counts across the whole file instead, and stores the answer as ToolDetail::shared — anything that divides a turn’s tokens by that count, or attributes the turn’s window growth to a single call, is wrong for every fan-out without it.

A resume forks. claude --resume X does not reopen X. It writes a new file under a new id which records X as the id it was launched with — the session_id field, as against sessionId, which is the file’s own. That launch id is what Session::launch_id holds, and matching a running process on it is what stops the live agent being attributed to the finished conversation. See proc::resumed_key, whose doc comment is the long version.

An oversized line used to lose its cost. A base64 image or a whole-file Write can push a line past MAX_JSONL_LINE_BYTES, and parsing it into a Value is what blows up memory. extract::slim_oversized re-reads such a line into a small object carrying only usage, so the turn’s tokens survive; a huge user entry, which carries no usage, is still dropped.

Sidechain turns are billed but not in the window. Older transcripts interleave subagent turns into the parent file flagged isSidechain. They cost this session real money, so they count towards cost — but they ran against a different context window, so in_context keeps them out of the breakdown, and last_main_model keeps a subagent’s model from naming the session.

An unknown model is an error, not a zero. Billable usage under a model resolve_claude cannot price sets SessionData::error, which the cache refuses to persist. A fabricated $0.00 would look exactly like a free plan.

Cost

Estimated: tokens times published rates, pricing::resolve_claude with a LiteLLM fallback. The general policy, and why the number will not match your invoice, is in What the cost figures mean.

Context

The only harness with a full breakdown, and the only one where a compaction is visible at all.

total and startup are measured — they are the window figures the API itself reported, startup being the first request of a segment, whose input is the system prompt, tool schemas, CLAUDE.md and skills index with no conversation in front of it. Everything else is estimated from transcript characters at CHARS_PER_TOKEN = 2.75, a constant fitted across 167 local sessions rather than assumed; it is low against the prose rule of thumb because this content is code, JSON and paths. Whatever the parts do not account for is unaccounted, reported signed, because “the categories add up to more than the window” is information rather than an error.

A segment is one stretch from a start or a compaction to the last request that measured itself. Mixing segments is the one way the type can lie without any number being wrong, which is why note_compaction retires the whole segment at once and seals it: a session that compacted and then stopped has no request in its new segment, so the sealed one is reported whole and flagged superseded.

Compactions are recognised two ways — isCompactSummary on the summary entry in newer transcripts, system/compact_boundary in older ones — and both the full extractor and the tail scan look for both, or the column and the panel would disagree about the same file.

The window’s size is resolved in one place, resolve_ctx_max, so nothing can name two ceilings for one window: a [1m] model string, then a pinned model in .claude/settings.local.json, .claude/settings.json, or the config root’s settings.json, then LiteLLM, then 200k. Observed usage outranks all of them — a settings file naming a 200k model must not report a 1M session as 239% full.

Assistant thinking blocks are deliberately not counted: Claude Code writes them with an empty thinking string and only the signature survives, so there is nothing to measure and it lands in the unaccounted gap honestly.

Subagents

Built by build_subagents from the sidechain files plus the parent’s Agent tool_use records. Three cases, all of which have bitten:

  • A transcript still being appended to outranks the parent’s tool_result. A background subagent is acknowledged the moment it starts, so taking that result at face value marked every one of them finished three seconds in. Silence for SUBAGENT_QUIET_MS (30s) is what counts as done.
  • Older agents wrote no toolUseId, so they are paired back to a parent tool_use by description, nearest start time winning.
  • Claude Code purges old subagent transcripts but keeps the parent’s call/result pair, so a row is synthesised from what survives and marked ghost.

Liveness

A per-session process, matched by --resume <uuid> forwarded through the fork, or by working directory when nothing on the command line says. Claude for Mac resumes by window title rather than id, so that is matched separately. Cowork sessions run in a VM and have no local process at all: activity within 90 seconds stands in, and CPU and memory stay blank by design.

The binary is not always called claude — the native installer and the web harness ship it as a version-named executable — so is_claude_binary trusts argv[0] and the install path as well as the process name.

permissionMode is stamped on every user record, so the permission column works without hooks; the newest one wins, since the user can change it mid-session. What the transcript cannot express is an agent blocked on a question: a held permission prompt writes nothing, so the newest record is a tool call in flight, which reads exactly like work. Only the hooks --install-hooks writes into $CLAUDE_CONFIG_DIR/settings.json say that outright.