SLOPSHOPPER

context-budget

Measures a Claude Code session's fixed startup context payload per item at a pinned binary version, including per-tool attribution of the built-in tool pools…

newpanebandcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-budget
│ ┃ context-budget-live ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ waiting for next response │ context-budget │ │ ⏺ Read(src/auth.ts) │ usage: /context-budget live │ │ ⎿ Read 6 lines │ [pane|band|off] │ │ ⏺ 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 │ │ › /context-budget │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context-budget-live
waiting for next response
README

context-budget

Measure a Claude Code session's fixed startup context payload per item, on your machine, at a pinned binary, and record what every trim actually saved.

Per-skill, per-agent, and per-MCP-tool attribution stays with the built-in /context command. This plugin covers what our measurements see /context report only as totals: the built-in tool pools, System tools and System tools (deferred). Together they are typically the largest single contributor to the fixed payload, which is why the plugin targets them. It attributes them per tool by A/B differencing: a baseline headless session versus one session per candidate tool with that tool denied by bare name. The two attributed buckets compose differently: deferred-side deltas add, so a basket's deferred saving is the sum of its members, while prefix-side deltas double-count, so their sum is only an upper bound on the basket's prefix saving.

Install

/plugin marketplace add melodic-software/claude-code-plugins
/plugin install context-budget@melodic-software

Skills

  • /context-budget:check. Read-only check that node resolves for the hook and the engine, so it can run unprompted when a hook notice says node is missing. Installs nothing.
  • /context-budget:setup. Read-only prerequisite check: node (the hook launches it by bare name, and a launch failure is non-blocking, so the checkpoint can be configured on yet never fire), the claude CLI the engine measures against, the optional Agent SDK that enables exact mode, and the effective settings_write_ask_enabled value. Installs nothing.
  • /context-budget:audit. Take a stamped baseline snapshot, attribute the built-in tool pools over the live tool list, present catalogue levers with their honesty categories, and ledger any before/after the operator produces. Read-only on bare invocation: it prints exact config (for persistent denies, a permissions.deny entry) and applies nothing. With the explicit fix argument, a guided per-lever walkthrough may edit project settings after per-diff approval. User-global ~/.claude/settings.json is only ever printed, and every applied lever is re-measured and ledgered before the next.

Live breakdown

A mod shows what the session's context is made of right now, by category, with each category's tokens, share of the window and theme color. It needs Claude Code 2.1.287 or later; mods are unsupported below that, and the plugin ships no shim (ADR 0052).

The live_display option picks where it shows:

  • pane (default). /context-budget live opens or closes a pane. Its header reads ◆ context <used> of <window> · compacts at <threshold> <percent>%, with the "compacts at" part only when auto-compaction is on. Each category follows with its tokens, percent and color; deferred categories are listed dimmed with "deferred" in place of a percent.
  • band. A bordered block of up to 5 rows above the prompt (header, bar, one-line legend), above other plugins' rows, fitted to the available width, and out of the way while a survey prompt shows. /context-budget live opens or closes the detail pane and leaves the band on; /context-budget live off hides the band. This multi-row band is an opt-in exception to the repository's one-row-per-mod display guidance (mods).
  • off. Nothing shows and nothing is measured.

/context-budget live pane, live band and live off set the mode for the session and survive a hot reload. In off mode, /context-budget live shows a toast that says how to turn the display on.

Refresh and cost. The mod reads Claude Code's local estimate of the breakdown, with no network call, when you open or switch the view and, while it is shown, when context usage changes. off makes no reads.

States. Before the first response, and right after a compaction, the pane says "waiting for next response" and the band draws nothing. When no breakdown is available, the pane shows the basic usage header and one "usage unavailable" line (the line alone when the basic figures fail too), and the band draws nothing.

Tested in: the plugin test kit (terminal and desktop surfaces) on Claude Code 2.1.296, plus claude plugin validate and tsc. No live interactive, --bg or Desktop session has run it yet.

Hook

A PreToolUse checkpoint returns permissionDecision: "ask" when a file-editing tool call (Write, Edit, NotebookEdit) targets a Claude Code settings file, so those edits prompt even in auto mode. It matches only the settings files Claude Code reads: settings.json in the user settings directory (CLAUDE_CONFIG_DIR, else ~/.claude), settings.json and settings.local.json in .claude/ under the project directory or the session's working directory, settings.local.json at the main checkout's root, plus managed-settings.json and managed-settings.d/ drop-ins in the managed system directory. A fixture or example named .claude/settings.json elsewhere does not prompt. Kill switch: the settings_write_ask_enabled plugin option.

It is a checkpoint, not a guarantee: other hooks, settings, and permission modes decide what happens to its ask. We state that whenever we describe the protection, and read the specifics live:

A mod (this plugin's or any other installed one) can keep PreToolUse settings hooks such as this plugin's settings-write checkpoint from running: they run after the last mod calls next, so a mod that answers a tool.call without calling it skips them (where settings hooks run in the order). A mod can also approve a call they blocked, because its tool.check hook runs after them (approve or refuse a tool call before the user is asked). As of 2026-10-11. Recheck when either section moves settings hooks relative to the mod chain.

What it does not see. The checkpoint matches tool names and file paths, not the file on disk. These routes change a settings file without an ask:

  • A write through the shell: a redirect, a heredoc, sed -i, cp, tee, or a script run by Bash or PowerShell. None of them passes through a file-editing tool.
  • A tool that renders configuration into place from a source elsewhere, such as a dotfile manager applying a template or symlink. That write is a legitimate action by another program, inside the session or outside it.

Claude Code's ConfigChange hook event fires on a settings change whatever wrote the file, but it reacts after the write rather than asking before it. That is a different control from this one, and this plugin does not register it.

Decision (#3864): the claim is narrowed, and the hook is not widened. Adding the shell lane would put an always-on Bash|PowerShell hook under the hook-budget ceiling on every shell call in every consumer. It would recognize a settings write only by guessing from the command string, and it would still miss the render-into-place route. So even after paying that cost, the checkpoint could not honestly claim every settings write. The /context-budget:audit fix path edits project settings through a file-editing tool, so the checkpoint does cover this plugin's own write path. Treat a future audit that finds this gap as re-filing it, not as a new defect.

The registration carries no if filter, so the hook process spawns on every matched write and the script decides. An if gate was evaluated for this row on 2026-09-02 and rejected. On Windows, Claude Code's if file rules do not match an absolute path outside the working directory under any anchoring form tested, including the home-relative ~/, the root-anchored //, the drive-letter spelling, and the root-anchored recursive glob. A probe session logged every candidate rule as skipped on a write to the user-global settings file and on a write to the managed-settings file, while the unconditioned row fired and returned ask for both. Only a settings file inside the working directory matched. Any gate on this row would therefore drop the user-global and managed-settings checks silently, which is the opposite of what the checkpoint exists to do. The row stays unconditioned until upstream matching reaches those paths.

What makes the numbers trustworthy

  • No figures ship; every number is measured locally. The skill contains no token figures, tool inventories, or thresholds. Those drift with every CLI release. Every number in a report was produced by a run on the consumer's machine during that audit.
  • Every report is stamped with the measured binary path and version, the measurement mode, and the session kind. Machines with two CLI installs get an answer per binary, not a blend.
  • Comparability is enforced, not advised. System tools deltas are only valid between runs with identical skill listings (listed skill frontmatter is subtracted from that bucket); the engine fingerprints the listing per run and marks violating comparisons incomparable rather than reporting their numbers.
  • Levers are catalogued data. Each row in skills/audit/reference/levers.json carries its honesty category (does it remove weight, work but save nothing here, block without saving, sit as vendor weight, or cost more than it buys), the official citation behind it, how to detect and measure it, the exact config it would emit, and a recheck trigger. A lever whose category cannot be determined for your configuration is not offered.
  • Honest degradation. Exact mode uses the Agent SDK's structured context usage. Without the SDK, the engine parses headless /context output version-aware (display-rounded, and flagged as resting on a surface the docs do not list for -p; the record is under "Degradation ladder" in skills/audit/reference/engine.md, relayed as of 2026-10-11; recheck this relay when that heading moves or its record's recheck trigger fires). When neither works, it emits a structured error with a remediation, never a wrong number.

Prerequisites

  • node (required, the engine's runtime).
  • The Claude Code CLI (claude on PATH, or pass the engine an explicit --binary).
  • Optional, for exact mode: @anthropic-ai/claude-agent-sdk, installed once into the plugin's data directory (the audit skill offers the command; it is the operator's call since it needs network access).

Data

Ledger and snapshots live under ${CLAUDE_PLUGIN_DATA}/audit/<state-key>/, keyed per project by the marketplace's shared state-key scheme, with one file per run plus an appended history line. An uninstall can delete this directory; the audit tells the operator so near the ledger.

Boundaries

  • Usage-based removal ("which plugins do I never use") belongs to the bundled /doctor; the skill routes there and never reimplements it. Pointer: <https://code.claude.com/docs/en/skills#check-your-setup-with-/doctor>. As of 2026-10-11. Recheck when that section drops unused MCP servers or plugins from its coverage.
  • Which skills to turn off goes to the built-in /skill-doctor, which the person runs; the audit measures what a toggle saved and never picks the skill. Pointer: <https://code.claude.com/docs/en/skills#find-unused-skills>. As of 2026-10-02. Recheck when that section sends the question to another command.
  • Per-skill / per-agent / per-MCP-tool attribution belongs to /context natively.
  • This plugin answers "what is my context made of": the startup measurement and the live breakdown. How full the context is and what happens as it fills (zones, notices to Claude, toasts, optional blocking, the one-line level row) belongs to the context-guard plugin.
  • Measurements describe headless sessions of the local CLI; interactive sessions and cloud/web surfaces can compose the payload differently, and reports say so.

Configuration

<!-- BEGIN GENERATED: plugin options. Edit plugin.json, then run scripts/sync-plugin-options-docs.py -->

Options reference

Generated from this plugin's .claude-plugin/plugin.json. Every option Claude Code will prompt for when the plugin is enabled, with the environment variable each hook reads it from.

OptionTypeDefaultEnvironment variableDescription
settings_write_ask_enabledbooleantrueCLAUDE_PLUGIN_OPTION_SETTINGS_WRITE_ASK_ENABLEDRuns the PreToolUse hook that asks before Write, Edit, and NotebookEdit calls aimed at a Claude Code settings file. On by default. Shell writes and files rendered into place are outside its matcher.
live_displaystring"pane"CLAUDE_PLUGIN_OPTION_LIVE_DISPLAYWhere the live context breakdown shows. pane opens it in a pane when you run /context-budget live; band keeps it above the prompt; off shows nothing and measures nothing. /context-budget live pane, band or off changes it for the session.

How to set these

Three supported routes, in the order most people want them:

  1. Interactively. Claude Code prompts for declared options when you enable the plugin. To change them later: /plugin configure context-budget@<marketplace>.
  2. Headless. Repeat --config for each option. Replace <marketplace> with the marketplace you installed this plugin from:
   claude plugin install context-budget@<marketplace> -s <scope> --config settings_write_ask_enabled=<value>

The same command reconfigures a plugin that is already installed: it prints already installed and still writes the value. The short-circuit message is about the install, not the config write. Do not claude plugin uninstall to reconfigure: uninstalling drops this plugin's whole stored pluginConfigs entry, resetting every option in the table above to its default. -s defaults to user, so pass the scope claude plugin list reports for this plugin. The verified-version record lives in the plugin-reconfiguration convention.

The value is stored immediately; the session you are in does not change. Hooks are handed their CLAUDE_PLUGIN_OPTION_* when the session starts, so start a fresh Claude Code session before expecting new behavior. A check run in the old session still reports the old value, and that is not a failed write.

  1. By hand, in settings. Add the value under pluginConfigs in your user settings (~/.claude/settings.json):
   {
     "pluginConfigs": {
       "context-budget@<marketplace>": {
         "options": {
           "settings_write_ask_enabled": <value>
         }
       }
     }
   }

Plugin option values are read from user, --settings, and managed settings only, not from a project's .claude/settings.json. To vary behavior per repository, enable or disable the plugin in that project's enabledPlugins instead of setting an option there.

Do not set the CLAUDE_PLUGIN_OPTION_* variables yourself. They are how Claude Code hands a configured value to a hook process; the value comes from the routes above.

Upstream documentation

<!-- END GENERATED: plugin options -->

Source 3 files
hooks/register.tsx 149 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { LiveMode, LiveState, LiveView } from '../types'
5import { Band, PaneBody } from './parts'
6
7const COMMAND = 'context-budget'
8const PANE = 'context-budget-live'
9const TITLE = 'Context'
10const USAGE = 'usage: /context-budget live [pane|band|off]'
11const MODES: readonly LiveMode[] = ['pane', 'band', 'off']
12
13const isMode = (value: unknown): value is LiveMode => MODES.includes(value as LiveMode)
14
15const live = atom({ plugin: 'context-budget', key: 'live' } as const, { view: null, error: null })
16
17// The header alone, from the window figures; none before a response is measured.
18const figures = (context: SessionContextUsage): LiveView | null =>
19  context.tokens === undefined ? null : { used: context.tokens, window: context.window, percent: context.percent ?? 0, categories: [] }
20
21const toView = (context: SessionContextUsage): LiveView | null => {
22  const b = context.breakdown
23  if (b === undefined) return figures(context)
24  return {
25    used: b.totalTokens,
26    window: b.rawMaxTokens,
27    ...(b.autoCompactThreshold !== undefined ? { compactsAt: b.autoCompactThreshold } : {}),
28    percent: b.percentage,
29    categories: b.categories.map(c => ({
30      name: c.name,
31      tokens: c.tokens,
32      percent: b.rawMaxTokens > 0 ? (c.tokens / b.rawMaxTokens) * 100 : 0,
33      kind: c.kind,
34      color: c.color,
35      deferred: c.isDeferred,
36    })),
37  }
38}
39
40// Each refresh takes a number; one that resolves after a newer one started writes nothing.
41let latest = 0
42
43const refresh = async ($: EngineInterface) => {
44  const seq = ++latest
45  let state: LiveState
46  try {
47    const { context } = await $.session.usage({ breakdown: 'summary' })
48    state =
49      context.tokens === undefined
50        ? { view: null, error: null }
51        : { view: toView(context), error: context.breakdown === undefined ? 'no breakdown in this session' : null }
52  } catch (err) {
53    // The breakdown failed: the plain figures still give the header, when they answer.
54    const plain = await $.session.usage().catch(() => undefined)
55    state = { view: plain === undefined ? null : figures(plain.context), error: err instanceof Error ? err.message : String(err) }
56  }
57  if (seq === latest) await update($, live, () => state)
58}
59
60const paneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE)
61
62const registerCommand = ($: EngineInterface) =>
63  $.command.register({
64    name: COMMAND,
65    description: 'Live per-category context breakdown: live toggles the pane; live pane, band or off sets the display',
66    argumentHint: 'live [pane|band|off]',
67    immediate: true,
68  })
69
70export const register: Register = (on, options) => {
71  // Unwritten, mode reads the option; a /context-budget live write outlasts a hot reload.
72  const mode = atom({ plugin: 'context-budget', key: 'mode' } as const, isMode(options.live_display) ? options.live_display : 'pane')
73
74  on('session.start', async ($, e, next) => {
75    await registerCommand($)
76    return next(e)
77  }).catch(($, e, next) => next(e))
78
79  // No session.start fires after /clear, /resume or /branch, so the next prompt registers the command
80  // again; registering again is harmless when the command survived.
81  let reregister = false
82
83  // The old conversation's figures go with it; a refresh still in flight writes nothing.
84  on('session.end', async ($, e, next) => {
85    reregister = true
86    latest++
87    await update($, live, () => ({ view: null, error: null }))
88    return next(e)
89  }).catch(($, e, next) => next(e))
90
91  on('prompt.submit', async ($, e, next) => {
92    if (reregister) {
93      reregister = false
94      await registerCommand($).catch(() => undefined)
95    }
96    return next(e)
97  }).catch(($, e, next) => next(e))
98
99  // Answers with no text: the pane or band is the output, and nothing lands in the transcript Claude reads.
100  on('command.run', { command: COMMAND }, async ($, e) => {
101    const [sub, arg, ...rest] = e.args.trim().split(/\s+/)
102    if (sub !== 'live' || rest.length > 0 || (arg !== undefined && !isMode(arg))) {
103      $.ui.toast(USAGE)
104      return {}
105    }
106    if (arg === undefined) {
107      if ((await read($, mode)) === 'off') {
108        $.ui.toast('live display is off: /context-budget live pane or band turns it on')
109      } else if (await paneOpen($)) {
110        await $.ui.close({ id: PANE })
111      } else {
112        await refresh($)
113        await $.ui.open({ id: PANE, title: TITLE })
114      }
115      return {}
116    }
117    await update($, mode, () => arg)
118    if (arg === 'off') {
119      if (await paneOpen($)) await $.ui.close({ id: PANE })
120      return {}
121    }
122    await refresh($)
123    if (arg === 'pane' && !(await paneOpen($))) await $.ui.open({ id: PANE, title: TITLE })
124    return {}
125  }).catch(($, e, next) => next(e))
126
127  // The breakdown is asked for only while it is shown and only when the context fill moved.
128  on('session.measure', async ($, e, next) => {
129    if (e.changed.includes('context') && ((await read($, mode)) === 'band' || (await paneOpen($)))) await refresh($)
130    return next(e)
131  }).catch(($, e, next) => next(e))
132
133  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => PaneBody($.ui.resolve(e), await read($, live), e.props.bodyColumns))
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const theirs = await next(e)
137    if (e.props.hasSurvey || (await read($, mode)) !== 'band') return theirs
138    const { view: v, error } = await read($, live)
139    if (v === null || v.categories.length === 0 || error !== null) return theirs
140    const { Box } = $.ui.resolve(e)
141    return (
142      <Box flexDirection="column">
143        {Band($.ui.resolve(e), v, e.props.bodyColumns)}
144        {theirs}
145      </Box>
146    )
147  }).catch(($, e, next) => next(e))
148}
149
hooks/parts.tsx 99 lines
1import type { Elements, RenderSurface, ThemeKey } from 'claude-code'
2
3import type { LiveCategory, LiveState, LiveView } from '../types'
4
5type Draw = Pick<Elements[RenderSurface], 'Box' | 'Text'>
6
7// 90000 -> 90k, 1000000 -> 1M, 1500000 -> 1.5M.
8// A value that rounds up to the next unit is shown in it: 999999 -> 1M, not 1000k.
9export const tokens = (n: number): string => {
10  const r = Math.round(n)
11  const k = Math.round(r / 1_000)
12  if (k >= 1_000) return `${+(r / 1_000_000).toFixed(1)}M`
13  if (r >= 1_000) return `${k}k`
14  return String(r)
15}
16
17export const headerText = (v: LiveView): string =>
18  `◆ context ${tokens(v.used)} of ${tokens(v.window)}${v.compactsAt !== undefined ? ` · compacts at ${tokens(v.compactsAt)}` : ''} ${Math.round(v.percent)}%`
19
20const entry = (c: LiveCategory): string =>
21  `${c.name} ${tokens(c.tokens)} ${c.deferred ? 'deferred' : `${Math.round(c.percent)}%`}`
22
23const isFree = (c: LiveCategory) => c.kind === 'free'
24
25// The engine documents a breakdown row's color as its key in the theme.
26const theme = (c: LiveCategory): ThemeKey => c.color as ThemeKey
27
28// One segment per window row, each as wide as its share of `width`, rounded on the running total
29// so the segments always add up to `width`. Deferred rows sit outside the window.
30export const Bar = ({ Box, Text }: Draw, categories: LiveCategory[], width: number) => {
31  const rows = categories.filter(c => !c.deferred)
32  const total = Math.max(1, rows.reduce((sum, c) => sum + c.tokens, 0))
33  let before = 0
34  const segments = rows.map(c => {
35    const start = Math.round((before / total) * width)
36    before += c.tokens
37    return { c, cells: Math.round((before / total) * width) - start }
38  })
39  return (
40    <Box flexDirection="row">
41      {segments
42        .filter(s => s.cells > 0)
43        .map(({ c, cells }) => (
44          <Text color={theme(c)} dimColor={isFree(c)}>
45            {(isFree(c) ? '░' : '█').repeat(cells)}
46          </Text>
47        ))}
48    </Box>
49  )
50}
51
52export const PaneBody = (draw: Draw, { view: v, error }: LiveState, columns: number) => {
53  const { Box, Text } = draw
54  return (
55    <Box flexDirection="column">
56      {v !== null ? (
57        <Text bold wrap="truncate">
58          {headerText(v)}
59        </Text>
60      ) : null}
61      {error !== null ? (
62        <Text dimColor wrap="truncate">{`usage unavailable: ${error}`}</Text>
63      ) : v === null ? (
64        <Text dimColor>waiting for next response</Text>
65      ) : null}
66      {v !== null && v.categories.length > 0 ? Bar(draw, v.categories, columns) : null}
67      {v?.categories.map(c => (
68        <Box flexDirection="row">
69          <Text color={theme(c)} dimColor={isFree(c) || c.deferred}>
70            {'■ '}
71          </Text>
72          <Text dimColor={c.deferred} wrap="truncate">
73            {entry(c)}
74          </Text>
75        </Box>
76      ))}
77    </Box>
78  )
79}
80
81// Five rows: the border's two, then header, bar and a one-line legend of the window rows.
82export const Band = (draw: Draw, v: LiveView, columns: number) => {
83  const { Box, Text } = draw
84  return (
85    <Box flexDirection="column" borderStyle="round" borderDimColor>
86      <Text bold wrap="truncate">
87        {headerText(v)}
88      </Text>
89      {Bar(draw, v.categories, columns - 2)}
90      <Text wrap="truncate">
91        {v.categories
92          .filter(c => !c.deferred)
93          .map(entry)
94          .join('  ')}
95      </Text>
96    </Box>
97  )
98}
99
types/index.d.ts 15 lines
1export type LiveMode = 'pane' | 'band' | 'off'
2// kind and color come from the breakdown row; branch on kind, never on name. color is a theme key.
3export type LiveCategory = { name: string; tokens: number; percent: number; kind: string; color: string; deferred: boolean }
4export type LiveView = { used: number; window: number; compactsAt?: number; percent: number; categories: LiveCategory[] }
5// One value so a refresh writes view and error together. Both null: no response measured yet.
6export type LiveState = { view: LiveView | null; error: string | null }
7
8declare module 'claude-code' {
9  interface PluginState {
10    // mode reads the live_display option until /context-budget live sets it; a hot reload keeps that
11    // write. Pane open or closed comes from $.ui.panes(), not state.
12    'context-budget': { mode: LiveMode; live: LiveState }
13  }
14}
15