Skip to main content
  1. Posts/

Every context ring read 10%. Two bugs, and fixing one hid the other.

··1568 words·8 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Deleting ClaudeDeck - This article is part of a series.
Part 3: This Article
Each agent key on the Stream Deck draws a ring showing how full that session's context window is. Mine sat at 10% for days. Every session, regardless of activity, regardless of how long the agent had been grinding. I found the bug, fixed it, watched agents start reporting correct percentages every single turn, and the ring still said 10%. That was the good part, because it meant there were two.
Tested with Claude Code 2.1.x · macOS

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

  1. Symptom

    day 1

    Ring pinned at 10% on every agent slot. Suspiciously round.
  2. 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.
  3. Fixed, and wrong

    day 2

    Agents now report a correct percentage every turn. The ring still reads 10%.
  4. Three live probes

    day 2

    pane.report_metadata emits nothing. pane.agent_status_changed carries no tokens. Tokens are assigned in exactly one place.
  5. 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.percentUsed

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

ProbeResult
Report metadata to a pane while subscribed to pane.updatedzero events for that pane. pane.report_metadata emits nothing at all
Inspect pane.agent_status_changed payloadcarries only {pane_id, agent_status}. No tokens
Grep the cache for token assignmentexactly 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
#

never re-seeds

connect

session.snapshot

tokens assigned

pushed events
pane_closed, status

cache state

10s timer

snapshot again

merge tokens only

never re-seeds

connect

session.snapshot

tokens assigned

pushed events
pane_closed, status

cache state

10s timer

snapshot again

merge tokens only

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

Deleting ClaudeDeck - This article is part of a series.
Part 3: This Article

Related

I deleted six subsystems by swapping one dependency. The protocol billed me.

Six of the ten posts in my "Building ClaudeDeck" series document code that no longer exists. Over four days in August I rebuilt the plugin on herdr's socket API, and the hook dispatcher, the PTY runner, the statusline auto-patcher, the AppleScript focus path, the Claude project watcher and the shell-PID resolver all went in the bin, taking the `.app` bundle, the codesigning step and every TCC prompt with them. Then the substrate sent its invoice. Deleting ClaudeDeck · Part 1 of 3 1 2 3 🧪 Tested with herdr 0.8.0 · macOS ClaudeDeck was complicated for exactly one reason. Claude Code has no control API, so every fact the plugin needed had to be stolen from somewhere it was not offered.

My backoff logged errors=1 492 times. It was answering the wrong question.

··1921 words·10 mins
The daemon's log contained the line `plan poller cadence: errors=1` four hundred and ninety-two times, and `errors=4` five times. A counter that is supposed to climb during an outage had spent its entire life bouncing off one. A third of my requests were being rate-limited and the backoff built to handle that never engaged once, because it was answering a question nobody had asked. Deleting ClaudeDeck · Part 2 of 3 1 2 3 🧪 Tested with herdr 0.8.0 · macOS The Plan Usage key on the Stream Deck renders my Claude plan’s 5-hour and 7-day windows with a countdown to the next reset. HerdDeck polls Anthropic’s OAuth usage endpoint to fill it. That endpoint is undocumented, which is a story I have already told on this blog and will be correcting later in this post.

Running six agents made tab patrol my biggest time sink. So: a herdr trial.

Once I had six Claude Code sessions open at once, the most expensive part of my workflow was not writing code. It was patrol: cycling through tabs to see which agent was still running and which one had been sitting on a question for ten minutes. tmux has no concept of any of this. To tmux, every pane is a rectangle of terminal, equally interesting, equally mute. herdr’s pitch lands exactly on that pain: panes are still real terminals, but a sidebar shows each agent’s live state. So it got a one-month trial, with exit conditions written down before it started. The verdict lands on August 16, and this post is honest about still being inside the window.