After an agent session hit the context ceiling and stopped answering for nineteen minutes, I wrote two small things: a status line showing how full the window is, and a hook that logs when automatic compaction fires.
Neither is complicated. What took thought was where to get the numbers from, and that turns out to be the part worth writing down.
The problem with reading it from the API
The obvious source is whatever the runtime hands you. My status line receives a JSON object on stdin, and inspecting the binary showed fields with promising names: context_window, exceeds_200k_tokens.
I did not use them.
A status line runs on every render. If a field name changes in an update, mine breaks, and the failure mode is that it silently shows nothing: no error, no warning, just a missing number in a place I have stopped consciously looking at. That is the worst kind of broken for a tool whose entire job is to tell me something I would otherwise forget.
So I read the transcript instead. Every session writes JSON Lines to disk, one entry per exchange, and each assistant entry carries a usage record:
That is the context size at that moment: everything sent, including what was served from cache. Cached tokens still occupy the window (they are cheaper, not absent) and leaving them out understates usage by most of it.
The transcript format is much more stable than any field on the way in, because it is also what the runtime reads back to resume a session. If that shape changes, more than my status line breaks.
Reading the tail, not the file
The session that prompted this was 566 MB. A status line that parses that on every render is not a status line, it is a pause.
Three megabytes from the end, take the last usage record. On the 566 MB file this runs in 63 milliseconds, and it would be the same on a file ten times larger.
The discarded first line matters. Seeking to an arbitrary byte lands mid-record, and a truncated JSON line either throws or, worse, parses into something wrong. One readline() before you start is the whole fix.
Guessing the ceiling
To show a percentage you need a denominator, and the window size depends on which model is running.
The second branch is the useful one. If the model identifier is unrecognisable (a new name, a different shape, an alias I have not seen) but usage is already above 200,000, then the window is obviously not 200,000. The observation corrects the guess.
It is not elegant. It is right in every case I have hit, including ones where the identifier told me nothing.
Never letting it fail loudly
A status line that throws is worse than one that is absent, because the error lands in the place your prompt is supposed to be, on every render. Whatever goes wrong, print an empty line and let the shell look normal.
That is also why the transcript path itself is treated as optional. Missing file, unreadable, a session with no assistant turns yet, all produce ctx - rather than a stack trace.
What it looks like
Three states: dim below 60%, amber at 70%, red with a message at 85%. The thresholds are the point: 70% is where I still have room to finish a thought and write notes, and 85% is where compaction is close enough that starting fresh is cheaper than continuing.
The hook is a backstop, not the primary signal
The second piece fires when automatic compaction begins:
It logs to a file and, if I am in a terminal multiplexer, prints to the status bar.
Compaction firing at all means the loop has already started, so this is late by design. The status line is meant to prevent what this records. What it adds is duration. "How long have I been dragging this session" is a fact I am bad at estimating, and the log turns it into a number: eighty days, in the case above, which I would have guessed at two weeks.
Computing session age needs the first timestamp, which means reading the head of the file rather than the tail:
Same shape as the tail read, opposite end.
The version that would have prevented it
Worth being honest about scope: neither of these fixes the underlying problem, which is that context refills faster than it empties.
They make it visible, and visibility is what I was missing. But a session with the status line still fills up; it just fills up where I can see it. The actual remedies are elsewhere: not dumping large command output into the transcript, and treating a session as a unit of work rather than a permanent workspace.
I'd rank the three by how much they helped:
Not dumping output into the transcript. By far the largest effect. My ten largest entries were single command results of about 300,000 tokens each, and redirecting those to a file removed the majority of the pressure.
Ending sessions at a natural boundary. Second largest, and the one that needs a habit rather than a tool.
The status line. Third, and it exists because the second one is a habit and habits fail. Its whole job is to make the moment for the second one obvious rather than something I have to remember to consider.
That ordering matters if you are deciding what to build. The instrumentation is the cheapest to add and the least effective of the three, and it is very easy to build it first, feel productive, and not change any behaviour.
Why a rule was not enough
I had already written down the rule. Six weeks before the session that died, I did the same investigation on a 45-day, 296 MB session, wrote a postmortem, and left myself a note saying to end sessions when the topic changes.
Then I did not do it, and the next session ran 80 days and 566 MB.
The reason is worth naming because it is not laziness. Continuing a session feels free at every individual moment. The context is there, the files are open, the model knows what you were doing. Starting fresh costs something you can feel immediately: re-explaining, re-opening, rebuilding the picture. The cost of continuing is real but arrives later and spread thin, so at each decision point the wrong choice looks cheaper.
A rule competes with your attention every time. A number that turns amber does not. That asymmetry is the entire argument for building the tool rather than trying harder, and it's why I stopped treating this as a discipline problem after the second postmortem.
What I would tell someone building the same thing
Derive from durable state, not from the interface. The transcript is written by the runtime and read by the runtime; it is much harder to break than an input field. Anything you can compute from durable state, compute from durable state.
Fail silent for display, fail loud for logs. A broken status line should show nothing. A broken hook should leave a record. Different jobs, opposite defaults.
Make the number continuous, not the alert. I initially thought about a notification at 80%. A percentage that is always visible works far better, since it changes when I glance at it, without demanding anything, and by the time an alert is justified I have already had twenty chances to notice.
Read the tail. Anything reading a growing log on every render needs to be O(1) in file size. Three megabytes and a discarded partial line covers it.
Whether it worked
Two weeks in: I have started three sessions specifically because the number was amber, which is three sessions that did not become the one that died. The compaction log has one entry from the day I built it and none since.
That is a small sample and a self-reported result, and I would be sceptical of it from someone else. What I can say more firmly is that the failure mode changed shape. Before, the context was invisible until it became a nineteen-minute silence. Now it is a number that gets slightly more orange, and the decision to stop happens while I still have room to write down where I was.