2.1.x · macOSI 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.
| Source | Has the window size? |
|---|---|
| statusline payload | yes, as context_window.context_window_size |
transcript .jsonl | no. message.model drops the [1m] long-context marker |
~/.claude/sessions/<pid>.json | no |
~/.claude/settings.json | only the default model, which is wrong after a /model switch |
| hook payloads | no, 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:
| statusline | transcript scan | |
|---|---|---|
| Percentage | exact, computed by Claude Code | needs the window constant |
| Coverage | every session | needed two fallbacks to match |
| Cost | none | one polling process |
| Requires | editing that machine’s statusline | nothing |
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#
- Claude Code statusline docs, the payload carrying
context_window.context_window_size: https://code.claude.com/docs/en/statusline - Claude Code hooks reference, on what hook payloads carry: https://code.claude.com/docs/en/hooks
- Anthropic models overview, per-model context windows: https://platform.claude.com/docs/en/about-claude/models/overview
- The statusline side channel this builds on: The Claude Code statusline is a per-turn telemetry side channel
- The ring this feeds: Every context ring read 10%. Two bugs, and fixing one hid the other.
herddeck-ctx-scan, the transcript scanner: https://github.com/nickboy/herddeck/blob/main/scripts/herddeck-ctx-scan
