Skip to content

✨ feat(agents): token usage per session and sub-agent - #43

Merged
winlp4ever merged 4 commits into
mainfrom
feat/agent-tokens
Jul 31, 2026
Merged

✨ feat(agents): token usage per session and sub-agent#43
winlp4ever merged 4 commits into
mainfrom
feat/agent-tokens

Conversation

@winlp4ever

Copy link
Copy Markdown
Contributor

What

The agents board now shows a compact N tok badge 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.

Stacked on #42 (feat/agent-tree-connectors) since it builds on that row structure — base is set to that branch. Merge #42 first, then this retargets to main.

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). On Stop (session → root) and SubagentStop (sub-agent → its own agent_transcript_path) we read that transcript, sum usage, and emit a synthetic TokenUsage event the renderer's pure reducer folds onto the matching node.

Headline badge = input + cacheCreate + output (excludes cache_read, the cheap repeated context, which otherwise dwarfs everything).

Zero terminal-hot-path cost (by construction)

  • Runs in the main process on the hook file-drop channel (already async + coalesced) — a different channel from PTY→renderer→xterm. Never touches keystrokes or rendering.
  • fs.promises → the actual read is on the libuv threadpool, not the main event loop.
  • Incremental: a per-transcript byte offset means we parse only the bytes appended since the last read, so a growing multi-MB transcript costs one turn's worth of lines, not a full re-scan.
  • Per-path serialization so two overlapping reads can't double-count.
  • Reducer case is targeted — sets tokens on an existing node only, never resurrecting a sub-agent already pruned at end-of-turn.

Files

  • electron/transcript-tokens.ts — pure line/chunk summing + TranscriptTokens incremental accumulator
  • electron/agent-tokens.ts — batch → TokenUsage events (Stop/SubagentStop; SessionEnd frees state)
  • src/lib/tokens.ts — renderer-safe headline/format/breakdown helpers (no fs)
  • agent-graph.tsTokenUsage type + node field + reducer case; normalizeHookEvent captures the two transcript paths
  • agents-panel.tsx + CSS — the badge

Tests

make check + typecheck green — 411 tests (+18): pure summing (incl. partial tail lines + multibyte byte-offset), incremental accumulation across appends, Stop/SubagentStop routing, reducer attribution + no-resurrection + unknown-session no-op, and the badge appearing only once usage is known.

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.
@winlp4ever
winlp4ever changed the base branch from feat/agent-tree-connectors to main July 31, 2026 08:44

@winlp4ever winlp4ever left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread electron/agent-hooks.ts
worktreePath: str(r.worktree_path),
baseBranch: str(r.base_branch),
transcriptPath: str(r.transcript_path),
agentTranscriptPath: str(r.agent_transcript_path),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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).

Comment thread electron/transcript-tokens.ts Outdated
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))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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).

Comment thread electron/agent-tokens.ts
continue
}
// Session turn finished → price the session transcript against the root.
if (ev.event === "Stop" && ev.transcriptPath) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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.

Comment thread electron/agent-tokens.ts
const out: AgentEvent[] = []
for (const ev of batch) {
if (ev.event === "SessionEnd" && ev.transcriptPath) {
tracker.forget(ev.transcriptPath)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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.

Comment thread electron/main.ts
// 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) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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.

Comment thread electron/main.ts Outdated
// 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[] =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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.
@winlp4ever

Copy link
Copy Markdown
Contributor Author

Addressed all 7 — thanks, the premise-checking was exactly right.

#1 (undocumented agent_transcript_path) — fixed by deriving instead. Verified on disk that Claude stores sub-agent transcripts at <session-transcript-without-.jsonl>/subagents/agent-<agentId>.jsonl (they carry message.usage + isSidechain:true). tokenEventsForBatch now derives that path from the transcript_path + agent_id both events already have, falling back to agent_transcript_path only if a version ever supplies it. The sub-agent half no longer hinges on an unverified field. (pure subagentTranscriptPath + a derived-path integration test)

#3 (large first read stalls the loop) — fixed. readOnce now reads+parses in bounded 1 MiB chunks and await setImmediate between slices, so a resumed 30 MB+ transcript's first Stop can't block PTY forwarding — each synchronous burst is one slice. Carry is kept as raw bytes across chunks and only complete lines (up to \n, a byte boundary) are decoded, so multibyte chars never split and the offset stays byte-exact. chunkBytes is injectable; new tests force tiny chunks over multibyte content + line boundaries.

#5 (sub-agent state never freed) — fixed. tokenEventsForBatch calls tracker.forget(subAgentPath) right after the terminal SubagentStop read (a sub-agent stops once), so the Maps no longer grow with fan-out.

#2 / #4 / #6 / #7 — documented (all graceful/eventually-consistent):

make check + typecheck green — 428 tests (+8). Still worth the one live check that badges light up on a real run, per your note.

…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.
@winlp4ever

Copy link
Copy Markdown
Contributor Author

Good catch — #7 is now actually fixed, not just documented. A WSL pane spawns as wsl.exe -d <distro>, so I parse+store that distro on its PtySession; since token events carry the paneId, transcriptTargets looks up the pane's real distro and builds its UNC-share candidates (falling back to the default distro only when the pane is unknown). Non-default-distro sessions now read tokens correctly. parseWslDistroArg is pure + tested. 430 tests, green.

@winlp4ever
winlp4ever merged commit 9f63340 into main Jul 31, 2026
4 checks passed
@winlp4ever

Copy link
Copy Markdown
Contributor Author

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 ↑↓:

  • ↑ context = the latest turn's input (input + cache_read + cache_create) = how full the context window is right now — bounded (~≤ the model window), not cumulative.
  • ↓ output = cumulative output generated (monotonic 'how much this agent produced').

e.g. ↑148k ↓1.2k. addLine overwrites context to each assistant line and accumulates output, so after a read context = the last turn. Dropped the old cumulative-sum path + addUsage. 427 tests green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant