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…

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.
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install context-budget@melodic-software
/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.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.
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:
disableAllHooks, Agent SDK permissions § Bypass permissions mode (hook evaluation order against the mode check), and headless § Turn off permission prompts in unattended runs (what a -p run does with a call that would prompt).ask, or a release note names hook evaluation order or disableAllHooks.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:
sed -i, cp, tee, or a script run by Bash or PowerShell. None of them passes through a file-editing tool.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.
ConfigChange an ask outcome or a visible message, or the Matcher patterns section gives PreToolUse a matcher on target path.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.
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.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./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.node (required, the engine's runtime).claude on PATH, or pass the engine an explicit --binary).@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).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.
/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./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./context natively.context-guard plugin.<!-- BEGIN GENERATED: plugin options. Edit plugin.json, then run scripts/sync-plugin-options-docs.py -->
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.
| Option | Type | Default | Environment variable | Description |
|---|---|---|---|---|
settings_write_ask_enabled | boolean | true | CLAUDE_PLUGIN_OPTION_SETTINGS_WRITE_ASK_ENABLED | Runs 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_display | string | "pane" | CLAUDE_PLUGIN_OPTION_LIVE_DISPLAY | Where 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. |
Three supported routes, in the order most people want them:
/plugin configure context-budget@<marketplace>.--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.
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.
userConfig schema and the CLAUDE_PLUGIN_OPTION_<KEY> export--config flag's reference entryenabledPlugins, extraKnownMarketplaces, pluginConfigs/plugin list<!-- END GENERATED: plugin options -->
hooks/register.tsx 149 lines1import { 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}
149hooks/parts.tsx 99 lines1import 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}
99types/index.d.ts 15 lines1export 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