✨ feat(agents): token usage per session and sub-agent - #43
Conversation
Claude Code's hooks don't carry token counts, but every hook payload references a transcript JSONL whose assistant lines embed a message.usage block. On Stop (session) and SubagentStop (sub-agent) we read the referenced transcript and sum usage, emitting a synthetic TokenUsage event the renderer's pure reducer folds onto the matching node. The board then shows a compact "N tok" badge per row (tooltip breaks down in/out/cache). Off the terminal hot path by construction: the read runs in the main process on the hook file-drop channel (already async + coalesced), uses fs.promises (libuv threadpool), and is incremental — a per-transcript byte offset means only the bytes appended since the last read are parsed, so a growing multi-MB transcript never costs more than one turn. Per-path serialization prevents two overlapping reads from double-counting. - electron/transcript-tokens.ts: pure line/chunk summing + TranscriptTokens accumulator - electron/agent-tokens.ts: batch → TokenUsage events (Stop/SubagentStop; SessionEnd frees) - src/lib/tokens.ts: renderer-safe headline + format helpers (no fs) - agent-graph: TokenUsage type/field + targeted reducer case (never resurrects a node)
On WSL, claude runs inside the distro and reports Linux transcript paths, but the token reader runs in the Windows main process and can't stat/open a Linux path. Resolve the reported path to the distro's UNC-share candidates (same wslTargets translation used by the files panel/preview) and read the first that exists; the accumulator stays keyed by the reported path so its offset is stable. Same-OS runs (Linux, macOS, native Windows) resolve to the path itself — identity, unchanged.
31c31e8 to
f37eff1
Compare
winlp4ever
left a comment
There was a problem hiding this comment.
Ran /code-review (high effort), and specifically verified the load-bearing premises against Claude's docs + real transcripts rather than taking them on faith. The incremental-read internals are genuinely careful and correct — byte offsets always land post-newline (no multibyte split), allocUnsafe bounded by bytesRead, rotation/truncation handled, per-path serialization, correct no-resurrection reducer guard; pure helpers well-tested; tsc + 40 token tests green. Seven findings inline. The top ones are premise issues (an undocumented field; parsing an unstable internal format; a large first-read stall) — #1 and #3 are the ones I'd gate merge on.
Evidence for the perf finding: local Claude transcripts on my machine reach 34 MB / 9,193 lines (max 90 MB), and the docs confirm --continue appends to the same file, so a resumed session's first Stop really does read+parse the whole thing on the main event loop.
| worktreePath: str(r.worktree_path), | ||
| baseBranch: str(r.base_branch), | ||
| transcriptPath: str(r.transcript_path), | ||
| agentTranscriptPath: str(r.agent_transcript_path), |
There was a problem hiding this comment.
#1 (correctness — top) — agent_transcript_path isn't in Claude's documented hook schema. The whole sub-agent-token path depends on it (tokenEventsForBatch's SubagentStop branch is gated on ev.agentTranscriptPath). The Claude Code hooks docs list transcript_path as a common field but not a separate agent_transcript_path; sub-agent transcripts are described via an internal subpath (subagents/agent-<id>), and SubagentStop is documented to provide last_assistant_message, not a distinct transcript path. If Claude doesn't actually send agent_transcript_path, str(r.agent_transcript_path) is undefined → the SubagentStop branch never fires → the entire sub-agent half of this feature silently produces no badges. Please confirm against a live SubagentStop payload; if the field is absent, derive the sub-agent transcript from transcript_path + the subagent subpath. (The docs don't exhaustively list per-event fields, so it may exist — but it's unverified and the feature quietly no-ops if it doesn't.)
|
|
||
| /** Add one transcript line's usage into `acc` (pure). Non-assistant / unparseable lines | ||
| * are ignored, so a partial or malformed tail line is simply a no-op. */ | ||
| export function addLine(acc: TokenUsage, line: string): TokenUsage { |
There was a problem hiding this comment.
#2 (altitude/robustness) — this parses a format Claude documents as internal and unstable. From the sessions docs: "The entry format is internal to Claude Code and changes between versions, so scripts that parse these files directly can break on any release," steering integrators to last_assistant_message (already in the hook payload), --output-format json, or the Agent SDK. So the token feature is fragile-by-construction: a future Claude release that renames/reshapes message.usage silently zeroes every badge with no error. It works today (verified against local transcripts), but there's no version-stability guarantee — worth a defensive comment, and longer-term preferring a supported source (e.g. last_assistant_message already carries the final message on Stop/SubagentStop).
| const len = st.size - from | ||
| const buf = Buffer.allocUnsafe(len) | ||
| const { bytesRead } = await handle.read(buf, 0, len, from) | ||
| const { usage: delta, consumed } = parseUsageChunk(buf.toString("utf8", 0, bytesRead)) |
There was a problem hiding this comment.
#3 (perf) — first read of a large transcript stalls the main event loop. Fresh sessions read small deltas, but claude --continue/--resume appends to the SAME file (docs), and local transcripts here reach 34 MB / 9,193 lines (max 90 MB). On the first Stop for such a resumed session, readOnce reads [0, st.size) — the whole file — then parseUsageChunk runs split('\n') + JSON.parse per line synchronously on the main event loop (fs is on the threadpool; the parse is not). ~9,000 parses → tens–hundreds of ms blocking PTY→renderer forwarding → terminal jank on that first turn, contradicting the 'one turn's worth / zero hot-path' claim for resumed sessions. Cap/stream the first read, or parse off the main loop (chunk + setImmediate, or a worker).
| continue | ||
| } | ||
| // Session turn finished → price the session transcript against the root. | ||
| if (ev.event === "Stop" && ev.transcriptPath) { |
There was a problem hiding this comment.
#4 (correctness) — async transcript lag under-counts the current turn. The hooks docs warn the transcript "is written asynchronously and may lag the in-memory conversation, so it may not yet include the current turn's most recent messages when a hook fires." So the read on Stop here can miss the assistant line for the turn that just ended → the emitted TokenUsage under-counts by that turn. The incremental design self-heals (the next Stop's read folds in the lagged bytes), but the badge shown immediately after a turn is momentarily stale/low. Minor and eventually-consistent — worth knowing the count trails by up to a turn.
| const out: AgentEvent[] = [] | ||
| for (const ev of batch) { | ||
| if (ev.event === "SessionEnd" && ev.transcriptPath) { | ||
| tracker.forget(ev.transcriptPath) |
There was a problem hiding this comment.
#5 (memory) — sub-agent accumulator state is never freed. SessionEnd does carry transcript_path (confirmed), so this correctly frees the session entry. But each sub-agent's agentTranscriptPath gets its own state+chains entry via update() and is never forgotten → across a long-lived app with heavy fan-out (hundreds/thousands of sub-agents), the two Maps grow unbounded (path string + usage + a retained promise each). Forget the sub-agent transcript's state after its final read on SubagentStop.
| // resulting token totals as a follow-up batch. The read is off the terminal hot | ||
| // path and incremental, so it never delays the events above or the agent's loop. | ||
| mainWindow?.webContents.send("agents:events", events) | ||
| void tokenEventsForBatch(agentTokens, events, transcriptTargets).then((tokenEvents) => { |
There was a problem hiding this comment.
#6 (correctness) — token totals can lose the race with end-of-turn pruning. onBatch sends the hook events, then void tokenEventsForBatch(...).then(send) sends tokens as a SECOND batch. The renderer has one ordered agents:events listener and IPC preserves send order, so if batch N (SubagentStop) has a slow transcript read and batch N+1's UserPromptSubmit prunes that finished sub-agent first, N's TokenUsage hits the reducer's no-resurrection guard → no node → dropped, and the badge never appears. Narrow for human-paced turns; realistic with the large first read (#3) or WSL-UNC latency.
| // context, so we infer: on Windows a POSIX-absolute path came from a WSL `claude` → read it | ||
| // via the (default) distro's UNC shares; otherwise it's already a host path. (Multi-distro | ||
| // uses the default distro — same limitation as the rest of the WSL fs bridge.) | ||
| const transcriptTargets = (p: string): string[] => |
There was a problem hiding this comment.
#7 (correctness, low) — non-default WSL distro reads 0 tokens. Hook events carry no WSL context, so transcriptTargets infers: Windows + POSIX-absolute path → wslTargets(p, {}) = wslUncCandidates(defaultWslDistro(), p). A claude in a non-default distro (wsl.exe -d Other) reports /home/..., resolved against the DEFAULT distro's share → the file isn't there → no candidate reachable → zeroed total → the badge silently stays 0/absent for every non-default-distro session. Graceful and consistent with the known WSL default-distro limitation, but a silent wrong-zero worth documenting.
… reads) - #1: derive the sub-agent transcript from transcript_path + agentId (<session-without-.jsonl>/subagents/agent-<id>.jsonl, verified on disk) instead of depending on the undocumented agent_transcript_path (kept as a fallback). The sub-agent half no longer silently no-ops. - #3: read+parse the transcript in bounded 1 MiB chunks that yield via setImmediate between slices, so a large first read (resumed 30 MB+ session) can't block the event loop / PTY forwarding. Byte-accurate carry across chunks (decodes complete lines only, so multibyte chars never split). chunkBytes injectable for boundary tests. - #5: free a sub-agent's accumulator state after its terminal SubagentStop read. - #2/#4/#6/#7: documented — internal-format fragility (degrades to no badge), transcript write-lag (count trails a turn, self-heals), the prune-race window, and non-default WSL distro reading zero. +8 tests: chunk-boundary + multibyte offset correctness, rotation reset, candidate fallback, derived sub-agent path, subagentTranscriptPath units.
|
Addressed all 7 — thanks, the premise-checking was exactly right. #1 (undocumented #3 (large first read stalls the loop) — fixed. #5 (sub-agent state never freed) — fixed. #2 / #4 / #6 / #7 — documented (all graceful/eventually-consistent):
|
…distro (#7) Non-default WSL distros no longer read zero. A WSL pane spawns as `wsl.exe -d <distro>`, so we parse and store the distro on its PtySession; token events carry the paneId, so transcriptTargets looks up that pane's distro and builds its UNC-share candidates instead of always assuming the default distro. Falls back to the default distro when the pane is unknown. parseWslDistroArg is pure + tested.
|
Good catch — #7 is now actually fixed, not just documented. A WSL pane spawns as |
|
Reworked the badge metric after live testing showed a session reading "22M tok" — that was my cumulative sum of input across every turn (technically real, but alarming and not what Claude's UI shows). Now it's the two numbers you'd actually want, matching Claude Code's ↑↓:
e.g. |
What
The agents board now shows a compact
N tokbadge on each session and sub-agent row (tooltip breaks it down↓ out · ↑ in · cache). This is the Claude-Code-style "tokens used per (sub-)agent" from the reference.Where the numbers come from
Claude Code's hooks don't include token counts — but every hook payload references a transcript JSONL whose assistant lines embed
message.usage(input_tokens/output_tokens/cache_creation_input_tokens/cache_read_input_tokens). OnStop(session → root) andSubagentStop(sub-agent → its ownagent_transcript_path) we read that transcript, sum usage, and emit a syntheticTokenUsageevent the renderer's pure reducer folds onto the matching node.Headline badge =
input + cacheCreate + output(excludescache_read, the cheap repeated context, which otherwise dwarfs everything).Zero terminal-hot-path cost (by construction)
fs.promises→ the actual read is on the libuv threadpool, not the main event loop.Files
electron/transcript-tokens.ts— pure line/chunk summing +TranscriptTokensincremental accumulatorelectron/agent-tokens.ts— batch →TokenUsageevents (Stop/SubagentStop;SessionEndfrees state)src/lib/tokens.ts— renderer-safe headline/format/breakdown helpers (nofs)agent-graph.ts—TokenUsagetype + node field + reducer case;normalizeHookEventcaptures the two transcript pathsagents-panel.tsx+ CSS — the badgeTests
make check+typecheckgreen — 411 tests (+18): pure summing (incl. partial tail lines + multibyte byte-offset), incremental accumulation across appends,Stop/SubagentStoprouting, reducer attribution + no-resurrection + unknown-session no-op, and the badge appearing only once usage is known.