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_DIRlets one machine hold several logins, each with its ownprojects/. cctop reads all of them: any~/.claude*directory holding a.credentials.jsoncounts as an account (config::PROFILED), andconfig::profile_forstamps 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_USERSand$CCTOP_HOMESadd other users’ homes to every root;Session::owneris stamped from the path inlist_all. - Claude for Mac.
~/Library/Application Support/Claude/{claude-code,local-agent-mode}-sessions/<account>/<device>/local_*.jsonis metadata, not a transcript:cliSessionIdpoints at a real Claude Code transcript nested under the session directory’s own.claude/projects, whichfind_desktop_jsonllocates. An entry flaggedisArchivedis skipped, and avmProcessNamemarks the Cowork surface (the agent runs in a cloud VM, so there is no local process — see the liveness section). The label comes fromuserSelectedFolders[0], falling back tocwd.
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 forSUBAGENT_QUIET_MS(30s) is what counts as done. - Older agents wrote no
toolUseId, so they are paired back to a parenttool_useby 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.