SLOPSHOPPER

context-receipt

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

newbandguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-receipt
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /receipt ⎿ context-receipt: Context: 97.4k / 200k (49%) ⎿ context-receipt: ⎿ context-receipt: Last turn added no tool results (growth is your prompt and Claude's reply). ⎿ context-receipt: ⎿ context-receipt: Estimates are for tool output only; /context gives the exact breakdown. ▤ ctx 49% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▤ ctx 49% ⟨Claude Code's own drawing⟩
README

Claude Code Mods: context receipt & context brake

Two small, observe-first Claude Code mods (function-hook plugins) for people who run long sessions and keep getting surprised by the context window.

ModWhat it doesReach
context-receiptAfter 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-brakeWhen 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.

context-receipt rendered from the real runtime

The GIF is drawn by scripts/make-demo.py from 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.

The problem

Context fill drives real decisions mid-session (keep going, delegate, compact, hand off), and the ambient indicator for it keeps disappearing:

  • anthropics/claude-code#90708 – Desktop app: bring back the context-usage indicator. Users describe sessions hitting auto-compact or "Prompt is too long" with no lead-up.
  • Related reports linked from that thread: #91232, #91385 (compaction with no warning), #102094, #102151 (context missing from the usage ring/popover).
  • anthropics/claude-code#91870 – the Claude Mods proposal, where several users ask for a way to have Claude stop "at a clean boundary and let it compact, rather than have compaction land in the middle of a verification run".

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:

  1. Attribution per tool result, per turn. A meter says "61%". The receipt says the 40k jump was one Read of a log file, so you know what to avoid next time.
  2. Telling Claude, not only you. A toast helps if you are watching. The brake puts the information where it changes behaviour: in front of the model, once, before compaction lands mid-task.

Both work in the terminal and the desktop app (the band uses the AbovePrompt component; the brake has no UI beyond a toast).

Install

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

Usage

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.

How it is built

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.

Testing

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.

Privacy

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.

License

MIT © 2026 biqpro SH. Not affiliated with Anthropic.

Source 3 files
hooks/context-receipt.mjs 107 lines
1// 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}
107
hooks/core.mjs 129 lines
1// 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}
129
types/index.d.ts 9 lines
1export 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