2.1.x · macOSTen percent is a suspicious number. Not zero, which would say “nothing ever arrived”. Not a plausible-looking 37%, which would say “this works and your session is small”. Ten percent is round, and round numbers in a display that should be noisy mean the display is not reading anything.
It turned out to be an orphan. Something wrote 10% into the cache a long time ago and nothing had overwritten it since, on any session, for days.
Symptom
day 1
Ring pinned at 10% on every agent slot. Suspiciously round.Bug one found
day 1
The statusline delegate reads.context_window.percentUsed. Claude Code emits.context_window.used_percentage. The extraction silently yields nothing, every turn.Fixed, and wrong
day 2
Agents now report a correct percentage every turn. The ring still reads 10%.Three live probes
day 2
pane.report_metadataemits nothing.pane.agent_status_changedcarries no tokens. Tokens are assigned in exactly one place.Root cause
day 2
The daemon snapshotted once on connect. Every ring froze at whatever it read at that instant.
Bug one: a field name that does not exist#
HerdDeck ships a statusline delegate. It sits in front of whatever statusline you already have, forwards the per-turn JSON payload on to herdr, and passes your original output through untouched. It read this:
.context_window.percentUsedClaude Code emits .context_window.used_percentage.
Getting the field name wrong is unremarkable. How it failed is the part worth keeping. A statusline script runs on every turn of every session, and a statusline script that throws takes your prompt down with it, so the whole thing is written to fall through silently on every path. Wrong field name, malformed JSON, jq missing, all of it produces the same result: no error, no output, no report, forever.
Silent-by-design is correct for a statusline. It is also a category of bug where nothing anywhere tells you the value never arrived, and the only symptom is a number somewhere else that stops changing.
There is a small joke at my expense here. I wrote a post about this exact payload in May, and that post prints the schema with the correct field name in it. My own published documentation had it right. The code I shipped three months later did not.
The fix keeps the wrong name deliberately, which was not my first instinct:
# Field name: Claude Code emits `.context_window.used_percentage`. An
# earlier revision of this script read `.percentUsed`, which does not
# exist — the extraction silently yielded nothing and the donut never
# lit up. `percentUsed` is kept only as a fallback so the script also
# works against any build that ever used it; `used_percentage` wins.
PCT="$(printf '%s' "$INPUT" | jq -r '.context_window | (.used_percentage // .percentUsed // empty)' 2>/dev/null)"Two tests pin the behaviour: one that the fallback works, one that used_percentage wins when both are present. A silent fallback needs a test asserting the ordering, or it is just a second way to be quietly wrong.
The fix worked and the ring did not move#
This is the part of the debugging that went wrong, and it is why the post exists.
After the field-name fix, every agent reported a correct, changing percentage on every turn. I could watch the numbers arrive. And the ring still sat at 10%.
For an hour or so I was debugging the wrong thing, because the evidence pointed backwards. A fix that produces correct data and no visible change reads as “the fix did not work”. I went back over the delegate. I checked the JSON. I checked that the daemon was receiving it. All fine, all pointless, because the second bug had been there the entire time and the first one had been hiding it perfectly.
A cosmetic symptom with two independent causes is much harder than a symptom with one hard failure. With one cause, fixing it resolves the symptom and you are done. With two, fixing the first one changes nothing observable, and the natural inference is that you were wrong about the first one.
Three probes against a live server#
So I stopped reading code and started asking the server questions. Three probes, against herdr 0.8.0, with a real agent working in a real pane.
| Probe | Result |
|---|---|
Report metadata to a pane while subscribed to pane.updated | zero events for that pane. pane.report_metadata emits nothing at all |
Inspect pane.agent_status_changed payload | carries only {pane_id, agent_status}. No tokens |
| Grep the cache for token assignment | exactly one site: the session.snapshot seed |
Put those together and the answer is unavoidable. The daemon snapshotted once on connect, and every ring froze at whatever the snapshot happened to contain at that instant. Nothing in the protocol was ever going to push a token count at it.
pane.updated looked like the answer#
There is an event called pane.updated and it does carry tokens. It looked like the fix for about ten minutes.
It is not, for two reasons, and the second one is decisive.
It is chatty. It fires on scroll offsets and status flicker, and the measurement recorded in the source is roughly 25 events in 2.5 seconds from a single active pane. Subscribing to that to catch a number that changes once per turn is a poor trade.
And it does not fire for a metadata report at all. That is the case that matters. A pane whose context moved while it sat idle would simply never be announced, which is precisely the situation where you want the ring to be honest, because you are looking at a key rather than at the terminal.
Rejecting an event on measurement rather than on taste is worth doing explicitly. pane.updated is the obvious answer, it is in the schema, and it would have shipped a ring that worked most of the time and lied when you were not watching.
Re-read, merge, do not re-seed#
The fix is a periodic snapshot on a 10-second default, and the merge is the load-bearing part:
/** Token re-read period; 0 disables it. Default 10s — roughly
* twice Claude Code's default statusline refresh, so the donut
* trails the real percentage by at most one report. */
tokensRefreshMs?: number;
// ...
this.tokensRefreshMs = opts?.tokensRefreshMs ?? 10_000;The timer’s snapshot can be older than pushed events the cache has already applied. Re-seeding from it would let a stale read undo a pane close that a newer event had already processed, resurrecting a pane that is gone. There is a test pinning exactly that, and its comment says why:
// The refresh re-reads a full snapshot but must merge tokens ONLY:
// re-seeding would let a stale snapshot undo a close that a newer
// pushed event already applied.
One correction to my own framing, because I described this as “tokens only” for a while and it stopped being true. The merge now also carries the focused flag, deliberately and with its own justification comment. If you go looking at that function expecting the name to be the specification, it will not be.
Lessons#
- A suspiciously round number in a noisy display means the display is not reading anything. Zero says “nothing arrived”, and a plausible number says “this works”. Round says “orphan”.
- Silent-by-design is right for a statusline and it removes your only failure signal. A wrong field name in a script built never to break a prompt produces no error, no output, and no report, on every turn, indefinitely.
- When a correct fix produces no visible change, consider that you fixed one of two bugs before concluding you fixed none. Two independent causes of one cosmetic symptom is the case where evidence points backwards.
- Probe the live server instead of reading its schema. “Does this call emit an event at all” is a question a schema cannot answer and a five-minute experiment can.
- Reject the obvious event on measurement, not taste, and write down the measurement.
pane.updatedcarried the field I wanted and did not fire for the case I needed. - A periodic re-read must merge, never re-seed. A refresh that is older than the events you have already applied will happily undo them.
- If a fallback exists, test the ordering, not just the fallback. Otherwise it is a second way to be quietly wrong.
References#
- Claude Code statusline docs, source of truth for the payload schema: https://code.claude.com/docs/en/statusline
- The post that printed the correct field name three months before I got it wrong: The Claude Code statusline is a per-turn telemetry side channel
- The rebuild this ring belongs to: I deleted six subsystems by swapping one dependency.
- HerdDeck statusline delegate:
scripts/herddeck-statusline.sh - HerdDeck state cache, including
mergeTokensFromSnapshot:packages/daemon/src/stateCache.ts - HerdDeck target monitor, the refresh timer:
packages/daemon/src/herdr/monitor.ts
