Summary
The transcript-summing path in src/utils/jsonl-metrics.ts counts several JSONL entries per API call, so tokens-cached, tokens-input, tokens-output and tokens-total over-report. Measured across 148 transcripts / 11,007 real API calls on ccstatusline 2.2.27 + Claude Code 2.1.222–2.1.228:
| widget |
shown |
actual |
over-report |
tokens-cached |
6,062,470,358 |
3,292,144,479 |
1.84x |
tokens-output |
20,390,026 |
9,484,311 |
2.15x |
tokens-input |
2,770,164 |
1,240,935 |
2.23x |
context-length and context-percentage-* are not affected — they read a single entry rather than summing.
Root cause
The dedup filter at jsonl-metrics.ts:207-212 assumes duplicate entries are streaming partials carrying stop_reason: null:
const entriesToCount = hasStopReasonField
? parsedEntries.filter((entry, index) => {
const stopReason = entry.data.message?.stop_reason;
return Boolean(stopReason) || (stopReason === null && index === parsedEntries.length - 1);
})
: parsedEntries;
That holds for one transcript shape but not the dominant one. Claude Code also writes one entry per content block — thinking, text, and each tool_use — and those entries all share a single message.id and the final non-null stop_reason. Boolean(stopReason) is true for every one of them, so none are filtered.
Grouping messages by shape:
| share |
shape |
filter outcome |
| 57.3% |
multi-entry, all entries carry a non-null stop_reason |
fails — every duplicate kept |
| 0.6% |
multi-entry, several non-null |
fails |
| 12.3% |
multi-entry, exactly one non-null (the streaming-partial case) |
works as intended |
| 29.8% |
single entry |
n/a |
So the filter is doing real work for the 12.3% — this isn't a "revert it" fix.
Why prompt-side fields are safe to dedup, and output needs care
Comparing entries that share a message.id, only output_tokens differs, and it is non-decreasing — the last entry carries the complete value. input_tokens, cache_read_input_tokens and cache_creation_input_tokens are byte-identical across an id's entries. Measured: 1,390 ids disagreed on usage, and in all 1,390 the differing field set was exactly {output}, always non-decreasing.
message …tlpxvdiq (3 JSONL entries, one message.id)
in=2 out=1 cread=3978 ccreate=12278 stop=None blocks=['text']
in=2 out=1 cread=3978 ccreate=12278 stop=None blocks=['tool_use']
in=2 out=287 cread=3978 ccreate=12278 stop=tool_use blocks=['tool_use']
Suggested fix
Dedup by message.id, keeping the entry with the highest output_tokens. This subsumes the streaming-partial case (the partial always has a lower output_tokens than the finalized entry), so it handles both shapes and doesn't depend on stop_reason semantics staying put.
// Claude Code writes one JSONL entry per content block (thinking / text / each
// tool_use). All entries for one API call share a message.id and repeat identical
// prompt-side usage; only output_tokens grows, and is complete on the last one.
// Dedup by id, keeping the highest output_tokens, then restore transcript order
// so the compaction / most-recent-entry logic below is unchanged.
const byId = new Map<string, { data: TranscriptLine; lineIndex: number }>();
const withoutId: typeof parsedEntries = [];
for (const entry of parsedEntries) {
const id = entry.data.message?.id;
if (typeof id !== 'string' || id.length === 0) {
withoutId.push(entry);
continue;
}
const prev = byId.get(id);
const out = entry.data.message?.usage?.output_tokens ?? 0;
const prevOut = prev?.data.message?.usage?.output_tokens ?? 0;
if (!prev || out >= prevOut) {
byId.set(id, entry);
}
}
const entriesToCount = [...byId.values(), ...withoutId]
.sort((a, b) => a.lineIndex - b.lineIndex);
Notes:
message.id was present on all 23,168 usage-bearing entries scanned, but the withoutId passthrough keeps the old behaviour if that ever changes.
- Live/in-flight updates are preserved: the current in-flight message still has an id, so it is still counted.
contextLength is unaffected, since it uses only prompt-side fields, which are identical across an id's entries.
hasStopReasonField becomes unused and can be dropped.
Repro
Run against your own transcripts — prints what ccstatusline counts vs. distinct message.id:
import json, glob, os, collections
root = os.path.expanduser("~/.claude/projects")
counted = ids = 0
for f in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
entries = []
for line in open(f, errors="replace"):
try: d = json.loads(line)
except Exception: continue
if (d.get("message") or {}).get("usage"): entries.append(d)
if not entries: continue
has_sr = any("stop_reason" in e["message"] for e in entries)
kept = [e for i, e in enumerate(entries)
if (e["message"].get("stop_reason")
or (e["message"].get("stop_reason") is None and i == len(entries) - 1))] if has_sr else entries
counted += len(kept)
ids += len({e["message"].get("id") for e in entries})
print(f"counted by ccstatusline : {counted:,}")
print(f"real API calls : {ids:,}")
print(f"inflation : {counted/ids:.2f}x")
Observed: counted 20,307 / real 11,007 / 1.84x.
Possibly related
#439 covered a different mismatch in the same equation (statusline context_window fields vs. transcript sums) and is closed. This one is entirely within the transcript-summing path, so the four widgets can be internally consistent and still all be ~1.8x high together.
Happy to send a PR if the approach looks right.
Summary
The transcript-summing path in
src/utils/jsonl-metrics.tscounts several JSONL entries per API call, sotokens-cached,tokens-input,tokens-outputandtokens-totalover-report. Measured across 148 transcripts / 11,007 real API calls on ccstatusline 2.2.27 + Claude Code 2.1.222–2.1.228:tokens-cachedtokens-outputtokens-inputcontext-lengthandcontext-percentage-*are not affected — they read a single entry rather than summing.Root cause
The dedup filter at
jsonl-metrics.ts:207-212assumes duplicate entries are streaming partials carryingstop_reason: null:That holds for one transcript shape but not the dominant one. Claude Code also writes one entry per content block —
thinking,text, and eachtool_use— and those entries all share a singlemessage.idand the final non-nullstop_reason.Boolean(stopReason)is true for every one of them, so none are filtered.Grouping messages by shape:
stop_reasonSo the filter is doing real work for the 12.3% — this isn't a "revert it" fix.
Why prompt-side fields are safe to dedup, and output needs care
Comparing entries that share a
message.id, onlyoutput_tokensdiffers, and it is non-decreasing — the last entry carries the complete value.input_tokens,cache_read_input_tokensandcache_creation_input_tokensare byte-identical across an id's entries. Measured: 1,390 ids disagreed on usage, and in all 1,390 the differing field set was exactly{output}, always non-decreasing.Suggested fix
Dedup by
message.id, keeping the entry with the highestoutput_tokens. This subsumes the streaming-partial case (the partial always has a loweroutput_tokensthan the finalized entry), so it handles both shapes and doesn't depend onstop_reasonsemantics staying put.Notes:
message.idwas present on all 23,168 usage-bearing entries scanned, but thewithoutIdpassthrough keeps the old behaviour if that ever changes.contextLengthis unaffected, since it uses only prompt-side fields, which are identical across an id's entries.hasStopReasonFieldbecomes unused and can be dropped.Repro
Run against your own transcripts — prints what ccstatusline counts vs. distinct
message.id:Observed:
counted 20,307 / real 11,007 / 1.84x.Possibly related
#439 covered a different mismatch in the same equation (statusline
context_windowfields vs. transcript sums) and is closed. This one is entirely within the transcript-summing path, so the four widgets can be internally consistent and still all be ~1.8x high together.Happy to send a PR if the approach looks right.