Itemised receipt of what each turn added to the context window: which tool results, how many tokens. Band above the prompt plus /receipt.

Two small, observe-first Claude Code mods (function-hook plugins) for people who run long sessions and keep getting surprised by the context window.
| Mod | What it does | Reach |
|---|---|---|
| context-receipt | After every turn, a one-line band above the prompt says how much the context grew and which tool results did it, e.g. ctx 61% +40k last turn · Read big.log ≈14k · Bash npm test ≈2k. /receipt lists the last turn and the session's biggest results. | Draws and remembers. No files, no network, no processes, no model calls. Never changes a tool call. |
| context-brake | When the context crosses your threshold (default 80%), Claude gets one short note with your next prompt: finish the current step, stop at a clean boundary, leave a handoff. You get a toast. Re-arms after /compact or /clear. | Adds one note beside one prompt per fill. Your prompt text is never edited. No files, no network, no processes, no model calls. |

The GIF is drawn by
scripts/make-demo.pyfrom band output produced by the real Claude Code runtime (claude plugin test, v2.1.301). The window chrome is synthetic; it is not a screen recording of a live session.
Context fill drives real decisions mid-session (keep going, delegate, compact, hand off), and the ambient indicator for it keeps disappearing:
There are already many good context meters in the community catalogue (token-weather, flightdeck, context-band, and others), and /context gives an exact category breakdown. These two mods cover what a percentage alone does not:
Read of a log file, so you know what to avoid next time.Both work in the terminal and the desktop app (the band uses the AbovePrompt component; the brake has no UI beyond a toast).
Requires Claude Code 2.1.287 or later (mods are on by default).
From GitHub (after this repo is published):
/plugin marketplace add jonirafael/claude-code-mods
/plugin install context-receipt@claude-code-mods
/plugin install context-brake@claude-code-mods
/reload-plugins
From a local clone:
claude --plugin-dir ./plugins/context-receipt --plugin-dir ./plugins/context-brake
# or
claude plugin marketplace add ./claude-code-mods
claude plugin install context-receipt@claude-code-mods --scope user
context-receipt
/receipt last turn and the biggest tool results this session
/receipt off|on hide or show the band
/receipt reset clear the history
Tool-result sizes are estimates (≈4 characters per token) of the text each tool returned to the model; the headline growth (+40k) is the real figure from $.session.usage(). Results under 50 tokens and subagent-internal tool calls are ignored (a subagent's final report still counts, as the Agent call). The band yields to surveys and passes through when there is nothing to show.
context-brake
/brake status: threshold, current fill, armed / sent / waiting
/brake 70 set the threshold (10–99), saved across sessions
/brake off disable
The note Claude receives (once per fill):
[context-brake] The context window is 83% full and auto-compaction is approaching. Finish the step you are on, then stop at a clean boundary instead of starting large new work. Before stopping, give a short handoff: goal, what is done, what is left, key files and decisions. This is a one-time note from a user-installed mod; the user may run /compact afterwards.
Plain ES modules, no build step, no dependencies:
plugins/<mod>/
├── .claude-plugin/plugin.json manifest (+ "types" contract for $.state)
├── hooks/hooks.json { "modules": ["./<mod>.mjs"] }
├── hooks/<mod>.mjs register(on): the hooks
├── hooks/core.mjs pure logic, unit tested
├── types/index.d.ts PluginState declarations
└── tests/*.test.ts run by `claude plugin test`
context-receipt hooks tool.call (observe: await next(e) then measure), turn.start/turn.complete (main loop only, !e.agentId), session.end, command.run, and ui.render{component=AbovePrompt}. History lives in $.state, so it survives hot reload, and reading state during render subscribes the band to redraws.context-brake hooks turn.complete to read $.session.usage(), and prompt.submit to forward the prompt with one extra entry in context (the prompt's list of side texts). The threshold is kept in $.store.npm run validate # claude plugin validate: manifest, hooks, $ calls, state contract
npm test # claude plugin test: unit + adapter tests against the real runtime
npm run demo # regenerate the GIF from runtime output
Status at v0.1.0, Claude Code 2.1.301 on Linux: validation passes for the marketplace and both plugins; 12 tests pass (8 context-receipt, 4 context-brake). The adapter tests drive the real mods runtime with stubbed session.usage, tool.call and prompt.submit. Not yet verified in a live, logged-in session (terminal or desktop). If something renders oddly, run claude --debug and please open an issue.
Neither mod reads files, runs processes, calls the network, or calls a model. context-receipt keeps labels such as Read session.ts or the first 28 characters of a Bash command in session state; it never stores tool output.
MIT © 2026 biqpro SH. Not affiliated with Anthropic.
hooks/context-receipt.mjs 107 lines1// context-receipt: an itemised receipt of what each turn added to the context window.
2import { answerText, bandParts, estimateTokens, labelFor, mergeTop, report } from "./core.mjs";
3
4const LAST = { plugin: "context-receipt", key: "last" };
5const TOP = { plugin: "context-receipt", key: "top" };
6const TURNS = { plugin: "context-receipt", key: "turns" };
7const HIDDEN = { plugin: "context-receipt", key: "hidden" };
8const MIN_TOKENS = 50; // ignore tiny results so the band stays readable
9
10// Items for the turn in flight. Reset at turn.start; a hot reload mid-turn loses only this turn.
11let pending = [];
12
13const val = (r, fallback) => r?.value ?? fallback;
14
15async function finishTurn($) {
16 const { context } = await $.session.usage();
17 if (!context?.window) return;
18 const tokens = context.tokens ?? 0;
19 const percent = context.percent ?? Math.round((tokens / context.window) * 100);
20 const prev = val(await $.state.get(LAST), null);
21 const turn = (val(await $.state.get(TURNS), 0)) + 1;
22 const items = pending;
23 pending = [];
24 const delta = prev && prev.window === context.window ? tokens - prev.tokens : null;
25 await $.state.set(LAST, { turn, delta, tokens, window: context.window, percent, items });
26 await $.state.set(TURNS, turn);
27 await $.state.set(TOP, mergeTop(val(await $.state.get(TOP), []), items));
28}
29
30export function register(on) {
31 on("session.start", async ($, e, next) => {
32 const result = await next(e);
33 try {
34 await $.command.register({ name: "receipt", description: "What filled the context last turn: /receipt [on|off|reset]" });
35 } catch { /* never block startup */ }
36 return result;
37 });
38
39 on("turn.start", async ($, e, next) => {
40 if (!e.agentId) pending = [];
41 return next(e);
42 });
43
44 // Observe only: the call and its answer pass through untouched.
45 on("tool.call", async ($, e, next) => {
46 const answer = await next(e);
47 if (!e.agentId) {
48 try {
49 const tokens = estimateTokens(answerText(answer));
50 if (tokens >= MIN_TOKENS) pending.push({ tool: String(e.tool), label: labelFor(e), tokens });
51 } catch { /* measuring must never affect the tool */ }
52 }
53 return answer;
54 });
55
56 on("turn.complete", async ($, e, next) => {
57 const result = await next(e);
58 if (!e.agentId) {
59 try { await finishTurn($); } catch { /* usage unavailable: skip this reading */ }
60 }
61 return result;
62 });
63
64 on("session.end", async ($, e, next) => {
65 const result = await next(e);
66 if (e.reason === "clear") {
67 pending = [];
68 try {
69 await $.state.set(LAST, null);
70 await $.state.set(TOP, []);
71 await $.state.set(TURNS, 0);
72 } catch { /* ignore */ }
73 }
74 return result;
75 });
76
77 on("command.run", { command: "receipt" }, async ($, e) => {
78 const arg = String(e.args ?? "").trim().toLowerCase();
79 if (arg === "off" || arg === "on") {
80 await $.state.set(HIDDEN, arg === "off");
81 return { text: `context-receipt band ${arg === "off" ? "hidden" : "shown"}.` };
82 }
83 if (arg === "reset") {
84 pending = [];
85 await $.state.set(LAST, null);
86 await $.state.set(TOP, []);
87 return { text: "context-receipt cleared." };
88 }
89 return { text: report(val(await $.state.get(LAST), null), val(await $.state.get(TOP), [])) };
90 });
91
92 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
93 const hidden = val(await $.state.get(HIDDEN), false);
94 const last = val(await $.state.get(LAST), null);
95 const parts = hidden || e.props?.hasSurvey ? null : bandParts(last, e.props?.bodyColumns ?? 80);
96 if (!parts) return next(e);
97 const { Box, Text } = $.ui.resolve(e);
98 const color = parts.level === "hot" ? "red" : parts.level === "warm" ? "yellow" : "green";
99 const children = [Text({ key: "cr-head", color, bold: true, children: `▤ ${parts.head}` })];
100 parts.items.forEach((s, i) => children.push(Text({ key: `cr-item-${i}`, dimColor: i > 0, children: ` · ${s}` })));
101 if (parts.more > 0) children.push(Text({ key: "cr-more", dimColor: true, children: ` · +${parts.more} more` }));
102 const band = Box({ key: "context-receipt", flexDirection: "row", paddingX: 1, children });
103 const below = await next(e);
104 return below ? Box({ flexDirection: "column", children: [band, below] }) : band;
105 });
106}
107hooks/core.mjs 129 lines1// Pure helpers for context-receipt. No `$`, no I/O: easy to unit test.
2
3/** Rough token estimate for text that enters the context (≈4 chars per token). */
4export function estimateTokens(text) {
5 if (typeof text !== "string" || text.length === 0) return 0;
6 return Math.ceil(text.length / 4);
7}
8
9/** The text a tool answer puts in front of the model. */
10export function answerText(answer) {
11 if (answer == null) return "";
12 if (typeof answer === "string") return answer;
13 if (typeof answer.text === "string") return answer.text;
14 if (typeof answer.deny === "string") return answer.deny;
15 try {
16 return JSON.stringify(answer.result ?? answer) ?? "";
17 } catch {
18 return "";
19 }
20}
21
22function clip(s, n) {
23 const flat = String(s).replace(/\s+/g, " ").trim();
24 return flat.length > n ? flat.slice(0, n - 1) + "…" : flat;
25}
26
27function base(p) {
28 const parts = String(p).split(/[\\/]/).filter(Boolean);
29 return parts[parts.length - 1] ?? String(p);
30}
31
32/** A short, human label for one tool call. */
33export function labelFor(e) {
34 const tool = String(e?.tool ?? "tool");
35 const pick = (...keys) => {
36 for (const k of keys) {
37 const v = e?.[k] ?? e?.input?.[k] ?? e?.args?.[k];
38 if (typeof v === "string" && v) return v;
39 }
40 return "";
41 };
42 let detail = "";
43 switch (tool) {
44 case "Read": case "Write": case "Edit": case "MultiEdit": case "NotebookEdit":
45 detail = base(pick("file_path", "notebook_path", "path")); break;
46 case "Bash": case "PowerShell":
47 detail = clip(pick("command"), 28); break;
48 case "Grep": case "Glob":
49 detail = clip(pick("pattern"), 24); break;
50 case "WebFetch": {
51 const url = pick("url");
52 const m = /^[a-z]+:\/\/([^/]+)/i.exec(url);
53 detail = m ? m[1] : clip(url, 24); break;
54 }
55 case "WebSearch":
56 detail = clip(pick("query"), 24); break;
57 case "Agent": case "Task":
58 detail = clip(pick("description", "subagent_type"), 24); break;
59 default:
60 if (tool.startsWith("mcp__")) return clip(tool.split("__").slice(1).join(":"), 32);
61 }
62 return detail ? `${tool} ${detail}` : tool;
63}
64
65/** Merge items with the same label and sort biggest first. */
66export function rank(items, limit = Infinity) {
67 const byLabel = new Map();
68 for (const it of items) {
69 const prev = byLabel.get(it.label);
70 if (prev) prev.tokens += it.tokens;
71 else byLabel.set(it.label, { ...it });
72 }
73 return [...byLabel.values()].sort((a, b) => b.tokens - a.tokens).slice(0, limit);
74}
75
76/** Keep the session's N biggest single items. */
77export function mergeTop(top, items, limit = 10) {
78 return [...top, ...items].sort((a, b) => b.tokens - a.tokens).slice(0, limit);
79}
80
81export function short(n) {
82 const a = Math.abs(n);
83 if (a >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
84 if (a >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
85 return String(Math.round(n));
86}
87
88/** Pieces of the one-line band. Returns null when there is nothing to show. */
89export function bandParts(receipt, columns = 80) {
90 if (!receipt) return null;
91 const head = receipt.delta == null
92 ? `ctx ${receipt.percent}%`
93 : `ctx ${receipt.percent}% ${receipt.delta >= 0 ? "+" : "−"}${short(Math.abs(receipt.delta))} last turn`;
94 const room = Math.max(0, columns - head.length - 4);
95 const items = [];
96 let used = 0;
97 const ranked = rank(receipt.items);
98 for (const it of ranked) {
99 const s = `${it.label} ≈${short(it.tokens)}`;
100 if (used + s.length + 3 > room) break;
101 items.push(s);
102 used += s.length + 3;
103 }
104 const more = ranked.length - items.length;
105 return { head, items, more, level: receipt.percent >= 80 ? "hot" : receipt.percent >= 50 ? "warm" : "cool" };
106}
107
108/** Text for /receipt. */
109export function report(last, top) {
110 const lines = [];
111 if (!last) return "context-receipt: no completed turn yet.";
112 lines.push(`Context: ${short(last.tokens)} / ${short(last.window)} (${last.percent}%)` +
113 (last.delta == null ? "" : `, ${last.delta >= 0 ? "+" : "−"}${short(Math.abs(last.delta))} last turn`));
114 const ranked = rank(last.items);
115 if (ranked.length) {
116 lines.push("", "Last turn, tool results (estimated, ≈4 chars/token):");
117 for (const it of ranked.slice(0, 10)) lines.push(` ≈${short(it.tokens).padStart(6)} ${it.label}`);
118 if (ranked.length > 10) lines.push(` … ${ranked.length - 10} more`);
119 } else {
120 lines.push("", "Last turn added no tool results (growth is your prompt and Claude's reply).");
121 }
122 if (top.length) {
123 lines.push("", "Biggest single tool results this session:");
124 for (const it of top) lines.push(` ≈${short(it.tokens).padStart(6)} ${it.label}`);
125 }
126 lines.push("", "Estimates are for tool output only; /context gives the exact breakdown.");
127 return lines.join("\n");
128}
129types/index.d.ts 9 lines1export type ReceiptItem = { tool: string; label: string; tokens: number }
2export type Receipt = { turn: number; delta: number | null; tokens: number; window: number; percent: number; items: ReceiptItem[] }
3
4declare module "claude-code" {
5 interface PluginState {
6 "context-receipt": { last: Receipt | null; top: ReceiptItem[]; turns: number; hidden: boolean }
7 }
8}
9