Skip to main content
  1. Posts/

The context percentage is a division. Nothing tells you the denominator.

··1310 words·7 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
Showing "this session is 34% full" requires two numbers. The token count is easy and always right. The window size it divides by is not in the transcript, not in the session state files, not in settings, and not in any hook payload. It exists in exactly one place, and if you cannot read that place you are guessing at the denominator of a number you are about to display as fact.
Tested with Claude Code 2.1.x · macOS

I built a script that reports how full a Claude Code session’s context window is, so a Stream Deck key could draw a ring for it. The token side was straightforward. The denominator turned into the whole project.

Five places the window size is not
#

I checked each of these against a live session rather than reasoning about what ought to be there.

SourceHas the window size?
statusline payloadyes, as context_window.context_window_size
transcript .jsonlno. message.model drops the [1m] long-context marker
~/.claude/sessions/<pid>.jsonno
~/.claude/settings.jsononly the default model, which is wrong after a /model switch
hook payloadsno, and the hooks documentation says so

The transcript row is the one that stings, because the transcript is otherwise a perfect source: it is on disk, Claude Code writes it without being asked, and reading it changes nothing. It carries message.model. What it does not carry is whether that session is running the long-context variant, because the marker is dropped from the model string it records.

The transcript can tell you which model. It can never tell you which window.

Why I rejected a model-to-window lookup table
#

The obvious fix is a table: model name in, window size out. I decided against it, and not because tables are inelegant.

A lookup table rots on every model release, and it rots in the bad direction. It does not throw. It does not warn. A model it has never heard of falls through to whatever default the author picked, and the tool goes on confidently reporting a percentage computed against the wrong denominator. Being silently wrong about a percentage is worse than showing nothing, because a number on a screen gets read as a measurement.

There is a real API answer here and it is worth stating precisely, because it is almost the fix. Anthropic’s Models API returns max_input_tokens per model id: machine-readable, and it does not rot. What it cannot do is disambiguate a variant that is not a distinct model id. [1m] is a session-level marker rather than something you can look up, and it is the exact thing the transcript drops. So you would be querying the right API with a key that has already lost the distinction you care about, and getting a confident answer to a question you did not ask.

What the script does instead
#

One assumption, named, in one place, with an override and a clamp:

# The one assumption in this script. Not a model->window table on purpose:
# such a table rots on every model release, and being silently wrong about
# a percentage is worse than not showing one.
CONTEXT_WINDOW_TOKENS = int(os.environ.get("HERDDECK_CONTEXT_WINDOW") or 1_000_000)

Three properties of that shape matter more than the value itself.

It is one constant rather than a table, so there is exactly one thing to be wrong about and one thing to change. It takes an environment override, so a machine running a different window fixes itself without a code change. And the overrun clamps:

pct = min(100, max(0, tokens * 100 // CONTEXT_WINDOW_TOKENS))

That clamp does real work. Without it, a session in a larger window than the constant assumes draws a ring at 216%, which is less a display bug than a confession. Clamping to 100 turns “my assumption is wrong” into “this session is full”, which is at least the right shape of wrong.

The deeper point is that only the final division depends on the guess. The token count, which is what the script actually derives from the transcript, is always correct. Show tokens and you are reporting. Show a percentage and you are asserting.

Two routes, and why both exist
#

There are two ways to get this number and they are not equivalent:

statuslinetranscript scan
Percentageexact, computed by Claude Codeneeds the window constant
Coverageevery sessionneeded two fallbacks to match
Costnoneone polling process
Requiresediting that machine’s statuslinenothing

The statusline route wins wherever the statusline is yours to edit, because Claude Code resolves the window itself and hands over an already-computed used_percentage. Reading a number somebody else computed correctly is the only approach here that cannot rot.

The scan exists for machines where the statusline is not yours. A company-managed statusline you must not touch is a real constraint, and the scan reads only files Claude Code already writes while changing nothing in anyone’s configuration.

Running both against four live sessions
#

Agreement between two independent routes is the only verification available when there is no ground truth to check against, so I ran them side by side. Three sessions agreed exactly: 34/34, 43/43, 16/16. The fourth converged once its turn completed.

Getting there took two fixes that only live data surfaced.

One pane out of four had no session id. herdr resolved three of four Claude panes and the fourth came back with agent_session: null. The fix needed no configuration: claude agents --json maps pid to session id, and herdr already knows each pane’s processes, so joining the two closes the gap.

That same pane was rate-limited, and its transcript lied. A rate-limited session’s transcript ends in <synthetic> records flagged isApiErrorMessage, carrying all-zero usage. Six of them in a row, in this case.

Read the newest usage record blindly and you get 0% for a session actually sitting at 34%. Not a small error, and not a random one. It always reports empty, and it reports empty exactly when the session is in trouble, which is when you are most likely to be looking at the key. “Use the newest record” is a reasonable-sounding heuristic that happens to select for the records carrying no information.

The fix is to skip records flagged as API errors while scanning backwards for the last real usage figure. Obvious once you have seen it, invisible until a session gets rate-limited while you happen to be watching.

Lessons
#

  • A percentage is a division, and the denominator deserves as much scrutiny as the numerator. Report the number you measured; you are only asserting the one you derived.
  • Prefer one named constant to a lookup table when neither is discoverable. A table rots per release and keeps answering; a constant is one thing to be wrong about and one thing to fix.
  • Clamp derived percentages at both ends. A 216% ring is your assumption failing in public, and the clamp converts it into the least-wrong reading available.
  • A machine-readable API answer is only useful if your key is unambiguous. Per-model context windows do not help when the field you hold has already dropped the variant marker.
  • When two independent routes to a number exist, run both against live data and compare. Exact agreement on three sessions surfaced two bugs neither route would have revealed alone.
  • Read the flags on a record before trusting its contents. Synthetic error records with zero usage will cheerfully answer “0%” for a session at 34%, and they show up precisely when things are going wrong.

References
#

Related

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

··1570 words·8 mins
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. Deleting ClaudeDeck · Part 3 of 5 1 2 3 4 5 🧪 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.

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

··1923 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 5 1 2 3 4 5 🧪 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.

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 5 1 2 3 4 5 🧪 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.