Real-time Claude Code status line that aggregates tokens across the main session and all subagents. Replaces the previous bash+jq implementation that only tracked main-session tokens.
Renders a multi-line status string consumed by the Claude Code status-line
hook. Three duration columns — work, wait, total (see
Time columns) — are ALWAYS visible and
close every table row in both layouts. With a prices.json present (see
Costs) the table additionally carries two extra
columns — model (the model id from the jsonl events) and cost
(money/credits spent) — and every group expands to one row per model:
Session: <sid> | Branch: <git-branch> | Model: <model> | User: n/a | Context: 215K (107%)
| model in out cached cost work wait total
| start: glm-5.3 12K 1K 0 10.7 credits
| sum: glm-5.3 1.1M 34K 50.8M 9.5K credits 28:03 00:08 28:11
| kimi-k3 150K 40K 3.0M $1.9
| MiniMax-M3 500 200 3K n/a
| main: glm-5.3 1.1M 30K 50.7M 9.4K credits 28:03 00:08 28:11
| kimi-k3 150K 40K 3.0M $1.9
| [ok] Review: implementation plan glm-5.3 12K 4K 100K 34.9 credits 01:12 00:03 01:15
| [err] Review: quality MiniMax-M3 500 200 3K n/a 00:26 00:05 00:31
| [run] Task 4: MissingGlyphLog 0 0 0 00:02 00:00 00:02
Without prices.json both columns disappear, each group renders a single
totals row — but the always-visible work / wait / total time columns
stay:
Session: <sid> | Branch: <git-branch> | Model: <model> | User: n/a | Context: 215K (107%)
| in out cached work wait total
| start: 12K 1K 0
| sum: 1.3M 74K 53.8M 28:03 00:08 28:11
| main: 1.2M 70K 53.7M 28:03 00:08 28:11
| [ok] Review: implementation plan 12K 4K 100K 01:12 00:03 01:15
| [err] Review: quality 500 200 3K 00:26 00:05 00:31
| [run] Task 4: MissingGlyphLog 0 0 0 00:02 00:00 00:02
Line layout:
- Line 1 — header (
Session: ... | Context: NK (P%)) - Line 2 — table header:
in / out / cachedright-aligned under their columns; with prices alsomodel(left-aligned, between the description andin) andcost(right-aligned, aftercached). The three time labelswork / wait / totalclose the header in BOTH layouts. The label/description column's header cell is empty. - Line 3 —
start:row with the FIRST assistant event's breakdown — the session's baseline message. A reference row: not included in thesum:aggregate; its three time cells are always empty; in prices mode it carries that event's model and its priced cost. sum:group (omitted if there are zero agents) — per-model merge of the main session and every agent (each side's usage already deduplicated bymessage.idfirst-wins); each model keeps its own row (no cross-model sums). Its time cells are the SESSION's union triple.main:group — cumulative breakdown of the main session, one row per model, with usage deduplicated bymessage.idfirst-wins (same rule as the agent groups). Same session time triple assum:— waiting on agents already counts as main's work (see Time columns).[skipped N agents]marker row — only when the session has more than 15 agents. Beyond the cap only the NEWEST 15 agents render (render order is oldest first), and this label-only row right aftermain:counts the rest. The cap is display-only:sum:and the session time triple still aggregate EVERY agent.- One group per agent —
[<status>]icon and description on the FIRST row of the group only. Totals are cumulative across ALL of the agent's events (not the last API call's usage), deduplicated bymessage.idfirst-wins: a split message is one record per content block each carrying the FULL usage, so each message counts once (records without an id count as-is); one row per model the agent used. The agent's personal work/wait/total render on that first row; continuation per-model rows leave the time cells blank.
Per-model rows whose tokens are all zero (e.g. <synthetic> events)
are skipped; a group left with no rows after that — an agent with no
events, or one whose events are all zero-token — still renders ONE row
with three zero cells and an empty model cell. Groups (and therefore
agents) are never skipped. The one exception is render-space capping,
which is a display cap rather than a skip (see the marker row above).
cache_creation tokens are tracked but NOT displayed and not priced.
Every table row (lines 2+) starts with the | marker: Claude Code
strips leading whitespace from status-line rows, and the marker keeps the
all-spaces table-header row aligned with the rows below it.
Each numeric cell is formatted via format_tokens (so 1000 renders
as 1K, 1_500_000 as 1.5M) and each duration cell via
format_duration (HH:MM). Every column's width — including the
sum: row's cells — is the widest cell under it (floored at 7 for the
token columns, at 5 for the duration columns — HH:MM fills that
exactly), so at extreme totals the columns can be one character
wider than the other rows suggested.
| Tag | Meaning |
|---|---|
[ok] |
last assistant event had stop_reason=end_turn |
[err] |
last assistant event had an API error marker |
[stop] |
meta.stoppedByUser=true OR user event with |
[Request interrupted by user] marker |
|
[run] |
mid-flow (last assistant had stop_reason=tool_use) |
[kill] |
queue-kill override from a main-log |
<status>killed</status> task-notification |
|
[lost] |
agent files vanished from all session dirs — |
| carried from the agents cache (see Edge cases) |
The [err] markers (error / isApiErrorMessage / apiErrorStatus>=400)
are looked up BOTH inside message (legacy event shape) AND at the event
top level: Claude Code 2.1.224 writes the synthetic API-error event (e.g.
a 429 death) with the markers as siblings of message and
stop_reason="stop_sequence" — without the top-level lookup such dead
agents classify as [run] forever.
The model and cost columns are opt-in: they appear only when a valid
prices file exists at ~/.claude/status_line/prices.json. The file is
private (gitignored) — copy prices.example.json
next to the module as a starting point:
[
{
"model": "glm-5.3@api.z.ai",
"in": 6.9,
"out": 24,
"cache": 1.7,
"per": 10000,
"units": "credits"
},
{
"model": "kimi-k3",
"in": 3,
"out": 15,
"cache": 0.3,
"per": 1000000,
"units": "$"
}
]Fields:
model— the lookup key. Either a plain model id (kimi-k3) ormodel@hostfor provider-specific pricing:hostis the hostname of theANTHROPIC_BASE_URLenv var the hook inherits from the Claude Code process (https://api.z.ai/api/anthropic→ keyglm-5.3@api.z.ai). Lookup order:model@hostfirst, then the baremodel. Duplicate keys: the last entry wins.in/out/cache— price perpertokens for input, output and cache-read respectively. Missing fields default to 0.per— token divisor, required: must be a number > 0.units— optional cost label. Its first character decides placement: non-alphanumeric glues as a prefix inside the cost cell ($8.1); anything else renders in a separate unlabeled column aftercost(402+crds), so the cost column's numbers right-align; empty or missing → the bare number.
Number formatting of the cost cell: >= 1M → X.XM, >= 1000 →
X.XK, >= 0.1 → one decimal with a trailing .0 dropped (402),
otherwise two decimals (0.04).
Cost of one per-model row =
(in·p_in + out·p_out + cached·p_cache) / per; cache_creation is not
priced (it is not displayed anywhere). The file is re-read on every
hook invocation and must be plain UTF-8 or UTF-8 with BOM.
What happens when parts are missing:
| Situation | Result |
|---|---|
| no / unreadable / invalid prices file | both columns absent — the plain layout |
| model known but not in the price file | n/a in the cost cell |
| group with no models after zero-skip | one zero row with an empty model cell |
The last three columns measure wall-clock time and are present in BOTH layouts (plan 20260827-status-line-time-columns):
total— elapsed session wall-clock:now − first_ts, wherefirst_tsis the FIRST timestamped event in the main jsonl (ISO 8601 stamps parsed to epoch seconds; a trailingZis handled by hand for Python 3.9).work— autonomous time: the UNION of all active intervals — the main session's turns plus every subagent's active lifetime — minus AskUserQuestion pauses. Waiting on a running agent counts as WORK (it is the machine doing what the user asked); parallel agents do NOT double-count overlapping wall-clock time (union, not a sum).wait— user-facing idle:total − work(clamped at 0). Includes the current unfinished pause.
By construction work + wait = total on the session rows (sum: /
main:). An AGENT row is exempt while one of its questions hangs
unanswered: its total freezes at the question moment, but the open gap
keeps growing its wait — so wait > total there is the honest picture
(the work the agent performed before asking is never erased).
How intervals are derived:
- Turns — the main scan splits the session at "real" user events
(
type=userwith string content: prompts, commands, interrupts;tool_resultlists do NOT bound anything). A turn spans from its prompt to the last activity carrying a timestamp; trailingqueue-operation/systemevents don't extend it — a notification about a background agent must not shift the start of the wait. - AskUserQuestion pauses — from an assistant event carrying an
AskUserQuestion
tool_useuntil the next user event of any kind. A pause is cut out of BOTH the containing turn's work and the asking agent's lifetime. While a question hangs UNANSWERED nothing accrues: the gap grows as wait, and the turn counts as closed (no live-now extension through it). - Live-now — when the last turn is still open (the session ended in
tool_use/pause_turn, tool results follow the last assistant event, or the last real prompt simply has no assistant reply yet) and when an agent's status is[run]without a hanging question, their LAST interval stretches up to the render moment —totalgrows in real time without new jsonl writes. A turn closes onend_turn, astop_sequence, an interrupt, an error, or a hanging question; an assistant reply WITHOUT astop_reason(an aborted/truncated generation) also counts as closed — the plan's "closed on error" rule, which bounds the damage of a crash to an undercount instead of counting unbounded dead air as work. - Agents — each agent renders its own triple:
total= lifetime (first → last stamped event, extended to now while running, frozen at the question moment while one hangs);work=total− Σ clipped closed AskUserQuestion pauses;wait= those closed pauses plus the OPEN question's gap up to now (uncapped — see the invariant note above). The agent's active intervals also feed the session union — which is why thesum:andmain:rows show IDENTICAL triples. While an agent's question hangs the session's own triple keeps counting the blocked time as WORK (waiting on agents is work by the union rule), even though the agent's personal row accrues it as wait.
Format: HH:MM via format_duration — seconds are dropped; hours are
unbounded (03:45, 103:25); truncation never rounds a minute up.
Degradation: an event without a parsable timestamp is silently skipped
for timing; a session or agent with NO usable stamps (or a legacy direct
call bypassing the orchestrator) renders EMPTY time cells — never
00:00. Missing data means unknown, not zero. Transient clock-skew
protection: work is clamped with min(work, total) so the invariant
work + wait = total survives resumed/multi-dir sessions where an
agent's stamps start before main's first timestamp.
This module lives at ~/.claude/status_line/. Claude Code invokes it
via the wrapper status_line.sh, which runs the first working Python
interpreter (python3, then python, then py; the Microsoft Store
python3 stub is skipped by probing). Under Cygwin the script path is
converted with cygpath -w so native Windows Python can read it.
No additional setup is required — the wrapper is referenced from Claude
Code's status-line hook configuration.
- Python 3.9+ (only stdlib used:
collections.abc,datetime,json,math,os,re,subprocess,sys,time,urllib.parse,pathlib,typing) giton$PATH(optional; absence is silently handled by returningbranch="")ANTHROPIC_BASE_URLenv var (optional): the hook inherits it from the Claude Code process and uses its hostname formodel@hostprice lookups (see Costs). Unset, invalid or scheme-less (no://) → no host → only plain model keys match.- Read access to
~/.claude/projects/<encoded>/<sid>/{*.jsonl,subagents/}and (optionally)~/.claude/status_line/prices.json - Write access to
~/.claude/status_line/data/(auto-created on first run)
main() is called by Claude Code via the wrapper. It:
- Reads the JSON hook payload from stdin.
- Resolves
session_id→ ALL matching session dirs~/.claude/projects/<encoded>/<sid>/via_resolve_session_dirs(a session id can legitimately exist in several encoded project dirs — e.g. the main checkout and a worktree copy; thetranscript_path's dir comes first, the rest in glob order). - Computes cumulative tokens for the main jsonl (
compute_main_cum), with on-disk cache at~/.claude/status_line/data/main_<sid>.json. - Iterates subagent jsonl files across ALL resolved dirs, computing a
per-agent snapshot (
compute_agent_snapshot) and merging them with dedup byagentId(first dir wins), using the agents cache at~/.claude/status_line/data/agents_<sid>.json; agents whose files are absent from every session dir are carried over from the cache with their last known state, a vanishedrunfreezing to[lost](see Edge cases). - Sorts agents by main-jsonl
tool_useposition (sort_agents). - Computes the time columns: with
now = time.time()the orchestrator unions main turns + agent lifetimes into the session work/wait/total triple and injects each agent's personal durations as TRANSIENT keys — after the agents-cache write, so they never persist (see Time columns). Tests freeze the clock by calling_main_unsafe(now=…)directly. - Renders the multi-line output (
render_output), wiring inprices.json, theANTHROPIC_BASE_URLhostname when a prices file exists (see Costs) and the time data. - Prints to stdout. Never returns non-zero.
Two cache files are persisted under ~/.claude/status_line/data/:
| File | Invalidation key | Purpose |
|---|---|---|
main_<sid>.json |
(last_uuid, mtime_jsonl) |
per-model breakdown + first-message start_* + context occupancy + tool_use ids + time segmentation (time_first_ts / time_turns / time_open) |
agents_<sid>.json |
(last_uuid, mtime_jsonl, mtime_meta) |
per-agent render-ready snapshot dict incl. time stamps (ts_first / ts_last / qa_pauses / qa_open_ts) |
Both main-cache field groups added after the first release
(context_tokens, start_in/start_out/start_cached, per_model,
and the time-segmentation trio time_first_ts/time_turns/time_open)
are part of the cache-hit check: a pre-upgrade cache file that matches
the key but lacks them is treated as a miss and rescanned once, then
rewritten in the new shape.
Usage-dedup migration note: the message.id dedup (plan
20260903-usage-dedup-by-message-id) does NOT change the cache schema,
so the hit check cannot detect it — cache files written before the fix
keep serving the old INFLATED token sums indefinitely (closed sessions
never mutate again, so the key never goes stale). Invalidating those
caches automatically (a _USAGE_REV-style revision key) was considered
and deliberately rejected in favor of a one-time manual cleanup:
rm ~/.claude/status_line/data/main_*.json ~/.claude/status_line/data/agents_*.jsonagents_<sid>.json also doubles as a retention store: entries whose
files have vanished from every session dir are re-emitted into the
output and written back (see the vanished-agent entry under Edge cases),
so deleting agents_*.json — as the one-time cleanup above recommends —
now also deletes that retained vanished-agent history.
Each per-agent entry in agents_<sid>.json is keyed by agentId and
holds the fields last_uuid, mtime_jsonl, mtime_meta, status,
status_rev, tokens_in, tokens_out, tokens_cached, models,
description, toolUseId, plus the four time-stamp fields ts_first,
ts_last, qa_pauses, qa_open_ts. The three tokens_* fields are the
CUMULATIVE breakdown columns rendered in the status line (input / output /
cache-read, summed over all of the agent's assistant events, deduplicated
by message.id first-wins — split-message records each carry the FULL
usage and count once per message); models
is the per-model breakdown feeding the model/cost columns; the four
time fields persist so cache-HIT cycles can still apply live-now
extensions and AskUserQuestion wait splits. Cache-hit requires ALL of
these breakdown + time fields to be present AND the entry's status_rev
to equal the code's current status-logic revision — a pre-upgrade cache
missing any of them invalidates and triggers a forward re-parse (see
Edge cases). The derived durations (time_work / time_wait /
time_total) are deliberately NOT here: they are recomputed and
injected into the agent dicts after every cache write by the
orchestrator.
status_rev deserves its own word: a dead agent's jsonl never mutates
again, so the (last_uuid, mtime) key would keep hitting a cache entry
forever — including a status value classified by an older, buggier
detect_status. The rev stamp (bumped whenever status classification
changes) turns such entries into misses: they are rescanned once and
rewritten with the corrected status.
Both files are written atomically (.tmp → os.replace()).
- No stdin (empty pipe): prints header with empty
session_id, exits 0. - Invalid JSON on stdin: same as above.
- Missing session dir (no
~/.claude/projects/.../<sid>/): header only, exits 0. - Permission denied on jsonl: cumulative reads fail → caller returns zero-valued result, render still works.
- Broken cache file: detected (
JSONDecodeErroror non-dict), deleted, recomputed. - Empty jsonl: returns zeros across the board, no crash.
- Subagent with zero assistant events: status forced to
[err](or[stop]ifmeta.stoppedByUser=true); the agent line is still emitted with three zero cells in the breakdown columns (never skipped). - Pre-upgrade agents cache (no breakdown fields): cache-hit
requires
tokens_in/tokens_out/tokens_cached/modelsplus the four time fields (ts_first/ts_last/qa_pauses/qa_open_ts) to be present, and the entry'sstatus_revto match the current code; if any fail, the entry is treated as a miss and the jsonl is re-scanned. After the first such re-scan the cache is rewritten with the new shape and subsequent calls hit cleanly. Without this check, a stale entry would render zeros (viaint(field or 0)), blank duration cells — or, for a pre-revstatus, a wrong[run]/[err]tag — until the next jsonl mutation (which, for a dead agent, never comes). - Missing / unparsable timestamps: events without an ISO 8601 stamp
are silently skipped for timing; a session or agent with no usable
stamps at all renders EMPTY work/wait/total cells (never
00:00). JSONnullin cached time fields passes the presence hit-guard but coerces to "no data" by the repo's usual defensive-read convention (_to_float, which also rejects booleans and non-finite junk — a bareNaN/Infinitya hand-edited cache parses to would otherwise poison the arithmetic into degrading the whole output) — the affected cells render empty rather than crashing the arithmetic. - Legacy direct call (
render_outputwithoutmain_time): rows keep their structure, every duration cell stays empty — only the header labels appear._main_unsafeitself takes a REQUIREDnow— there is no blank-time orchestrator mode that could silently hide a wiring mistake. - Stale empty
agents_<sid>.json(written by pre-2026-08 versions that scanned only one session dir — the worktree-split bug): rewritten automatically on the next invocation once agents are found across all session dirs; no manual cleanup needed. - Same session id in multiple project dirs (main checkout + worktree
copy): all matching dirs are resolved, agents merged across them, and
duplicates deduped by
agentId— thetranscript_path's dir wins; without atranscript_path, glob order decides. - Vanished agent files (CC can delete
subagents/agent-*.jsonlwhen a session changes cwd or is restarted — observed 2026-09-05: the session files move between project folders while the agent files are lost): vanished agents are retained from the per-session agents cache with their last-known status/tokens/time; a vanished running agent is frozen to[lost], and any open question is closed — for terminal ghosts too — so the wait column stops growing (when the unanswered question is the agent's last event, the wait accrued since it has no knowable end bound and is dropped: the wait cell may shrink at the loss render, then freezes); the rows live until the session ends; if a file returns to disk, the fresh scan wins. - Missing/invalid
prices.json(no file, broken JSON, non-list, invalid entry):load_pricesreturnsNone→ the model/cost columns are absent, the plain layout renders. Same for an unsetANTHROPIC_BASE_URL: plain model keys still match,@hostkeys never do.
Run all tests:
cd ~/.claude/status_line
python -m pytest tests/ -v533 tests cover: pure functions (format_tokens, format_duration,
union_work, _parse_ts in tests/test_format_duration.py /
tests/test_union_work.py, the agent pause/trim/extension geometry in
tests/test_agent_time_segments.py, detect_status, parse_stdin),
price helpers (provider_host, load_prices, price_for,
compute_cost, format_cost), I/O helpers (compute_main_cum,
compute_agent_snapshot, find_session_dir(s), _resolve_session_dirs,
sort_agents, _write_agents_cache) including the usage dedup by
message.id (split-message fixtures for both scans), the main/agent time
segmentation and cache presence guards (tests/test_time_segmentation.py),
render_table and render_output (model/cost columns, per-model groups,
the start: row, the always-visible work/wait/total block),
main() end-to-end against a real session fixture — including the
multi-dir merge across duplicate session dirs
(tests/test_resolve_session_dirs.py, tests/test_find_session_dir.py)
and the work/wait/total invariants (work + wait == total within ±1s,
identical main: / sum: triples) checked in a now-independent way — a
frozen-clock suite calling _main_unsafe(now=…) in-process that also
pins the live-now extensions (open main turn, run agent), the hanging-QA
agent triple, the min(work, total) and clock-skew clamps, the
null-time-fields cache-hit degrade, and stray bare-NaN/Infinity transient
time fields in a hand-corrupted agents cache (blank cells, never a crash)
— a runtime smoke test — and the bash wrapper.
tests/fixtures/real_session/ contains a copy of session
f5044e4f-3e01-4330-be72-eb008a1d035e (38 subagents) used by
test_main_integration.py. The directory is gitignored — populate it
after a fresh clone (see tests/fixtures/real_session/README.md).