SLOPSHOPPER

tokendoctor

Shows where Claude Code tokens go, audits a setup for settings that waste them, and changes a setting only after you approve the diff.

newguardtoolprocess
★ 9v0.5.0MITupdated 2026-10-26rsaryev/tokendoctor
A shopper browsing a rack in a slop shop
README

<img src="docs/logo.svg" width="72" height="72" alt="tokendoctor">

<h1 align="center">tokendoctor</h1>

<strong>See where your Claude Code tokens go, and stop wasting them</strong>

A Claude Code plugin that measures your real spend, audits your setup against the docs, and changes a setting only after you approve the exact diff. Everything runs on your machine.

<a href="https://tokendoctor.dev">Website</a> · <a href="#install">Install</a> · <a href="CHANGELOG.md">Changelog</a>

<img src="docs/demo.gif" alt="tokendoctor in a real Claude Code session: /tokendoctor:sessions, then the daily trend" width="880">

<table> <tr> <td width="33%" valign="top"> <strong>Measure</strong><br> <code>/tokendoctor:sessions</code> reads your local transcripts: main thread against subagents, spend with a very full context, cache rebuilds. A daily history and before/after snapshots show whether a change helped. </td> <td width="33%" valign="top"> <strong>Audit</strong><br> <code>/tokendoctor:audit</code> runs 33 checks against documented Claude Code behaviour. Each finding links to the docs page it rests on. </td> <td width="33%" valign="top"> <strong>Tune</strong><br> <code>/tokendoctor:optimize</code> proposes changes backed by your own numbers and applies the ones you pick, each through a dialog that shows it. </td> </tr> </table>

Install

claude plugin marketplace add rsaryev/tokendoctor
claude plugin install tokendoctor@tokendoctor

Update with claude plugin marketplace update tokendoctor and claude plugin update tokendoctor@tokendoctor, then restart Claude Code. To try it without installing, clone the repository and run claude --plugin-dir /path/to/tokendoctor.

Use

CommandWhat it does
/tokendoctor:sessions [days]Where tokens went over the last 30 days (or days), with findings. Read-only.
/tokendoctor:auditSetting checks by severity, each linked to the docs. Read-only.
/tokendoctor:optimize [focus]Proposes up to five changes from your numbers and applies the ones you pick.
/tokendoctor:apply <finding-id>Applies one audit fix after a dry run, with a backup of the file.
/tokendoctor:bench save <label> · compareSaves a before/after snapshot of token use, or compares the saved ones, per machine and combined.

optimize changes only settings on the tunable list and /config rows. Every change opens a dialog that shows it and runs only on Apply; with no one to ask, as in a -p run, nothing changes. Hooks and permissions are never on the list.

The plugin's bin/ directory is on the Bash tool's PATH while it is enabled:

tokendoctor-audit [--project <dir>] [--json]
tokendoctor-sessions [--days <n> | --since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--project <dir>] [--by day|week] [--json]
tokendoctor-bench save --label <name> [--machine <name>] [--dir <dir>] [--days <n> | --since <date>] [--until <date>]
tokendoctor-bench compare [--dir <dir>] [--json]
tokendoctor-set --list | <key> <value>|--unset [--scope user|project|local] [--write]   # also modelSettings.<model id>.effortLevel
tokendoctor-apply <finding-id> [--scope user|project|local] [--project <dir>] [--write]

The skills are user-invoked only (disable-model-invocation). claude plugin details tokendoctor@tokendoctor estimates about 205 tokens of always-on cost for the five of them. The plugin has no MCP server. Its hooks module adds the optimize tools (mcp__tokendoctor__*) behind ToolSearch, so only their names are listed until /tokendoctor:optimize loads them. Its one command hook runs tokendoctor-record in the background when a session starts. The hook is async and prints nothing, so it adds no tokens. It appends each finished day's totals to ~/.claude/tokendoctor/history.jsonl, so the trend outlives Claude Code's transcript cleanup.

Errors are settings that are wrong or ignored, warnings are likely to cost real money, and info is worth a look. Rules that depend on a Claude Code release are skipped on older versions.

FindingSeverityWhat it catches
settings-unreadableerrorA settings file that exists but is not valid JSON, so nothing in it applies.
compact-threshold-undocumentederrorautoCompactThreshold is not a Claude Code setting; the real key is autoCompactWindow. Fix removes it.
compact-env-bare-numbererrorCLAUDE_CODE_AUTO_COMPACT_WINDOW below 100,000 (a bare number is tokens, not thousands).
compact-window-unsetwarnModels with a native 1M window compact only near 967K by default. Fix sets 400,000.
compact-window-smallwarnA window so small that frequent compaction breaks the prompt cache.
prompt-caching-disabledwarnA DISABLE_PROMPT_CACHING* variable is set.
subagent-model-forcewarnEvery subagent, including reasoning ones, is forced onto one model.
effort-max-in-settingswarneffortLevel: "max" is not accepted in settings. Fix uses xhigh.
advisor-inactivewarnAn advisor that will not run (telemetry or feature-flag fetching off, an advisor alias below the main model's, tool disabled).
fast-mode-enabledwarnfastMode: true saved as a default, so every session pays the higher fast-mode rate. Fix sets fastModePerSessionOptIn.
auto-compact-offwarnDISABLE_AUTO_COMPACT or autoCompactEnabled: false: history grows until the model window fills. Fix unsets the setting.
tool-search-offwarn/infoENABLE_TOOL_SEARCH=false loads every MCP tool definition into every request.
explore-default-modelinfoThe built-in Explore agent runs on the main model (Opus when that is Fable), and CLAUDE_CODE_SUBAGENT_MODEL does not move it. Fix installs a Haiku, read-only override.
subagent-model-unsetinfoGeneral-purpose subagents inherit an expensive main model. Fix sets CLAUDE_CODE_SUBAGENT_MODEL=sonnet.
effort-above-defaultinfoA level saved per model by /effort above that model's default (Opus 5.5 at xhigh, say). Fix removes the saved level.
effort-user-key-ignoredinfoA top-level effortLevel in user settings, which Opus 5.5 and later models do not use.
main-model-expensiveinfoOpus or Fable as the default; suggests Sonnet.
agents-inherit-modelinfoCustom agents with no model run on the main model.
heavy-dirs-not-deniedinfoLarge target/, dist/, build/, coverage/, .turbo/ directories not excluded from reads. Fix adds permissions.deny rules.
large-lockfile-readableinfoLockfiles over 200 KB that a model can open in full. Fix adds a deny rule.
instructions-longinfoCLAUDE.md plus its @imports over 200 lines. Path-scoped .claude/rules and skills cut the cost; imports do not.
compact-instructions-missinginfoNo # Compact instructions section to steer summaries.
context-surfaceinfoMany skills, plugins or MCP servers loaded in every session.
duplicate-skillsinfoThe same skill at user and project level.
thinking-tokens-envinfoMAX_THINKING_TOKENS is ignored on adaptive-reasoning models.
prompt-cache-ttl-forced-shortinfoFORCE_PROMPT_CACHING_5M removes the one-hour cache some plans get.
agent-teams-enabledinfoAgent teams multiply token use per teammate.
mcp-output-limit-raisedinfoA raised MAX_MCP_OUTPUT_TOKENS.
every-tool-hooksinfoHooks matching * on a tool event (Pre/PostToolUse, PostToolUseFailure, PermissionRequest) start a process per call. Latency, not tokens. Fix drops them after a dry run; read the warning first if a tool shows agent status from them.
idle-compaction-offinfoidleCompaction: false: a long conversation is not compacted before its one-hour cache expires. Fix unsets it.
bash-output-raisedinfobashOutputMaxChars or BASH_MAX_OUTPUT_LENGTH above the 30,000 default.
code-intelligence-missinginfoThe project has files for a language with an official language-server plugin (typescript-lsp, pyright-lsp, gopls-lsp, …) and it is not enabled.
no-statuslineinfoNo status line. Fix installs tokendoctor-statusline.sh and points statusLine at it (needs jq). One quiet line: Opus 5.5 · api main* · 120k/367k · cache 1h · 5h 51% · week 35% · $24. Tokens are shown against the point where Claude Code compacts; cache 1h is how long the prompt cache stays warm; a limit adds → 100% in 48m only when, at the recent pace (5h: the last hour, week: the last day), it runs out before it resets. The finding also mentions the token-weather mod from anthropics/claude-code-playground as an alternative (needs Claude Code 2.1.287+, shared as-is).

templates/statusline.sh is the status line script the fix installs into your Claude config folder. templates/agents/ holds three generic subagents (Explore, test-runner, docs-lookup), each pinned to a small model with read-only or narrow tools. A plugin cannot override a built-in agent or ship settings.json keys such as autoCompactWindow, env or permissions, so the apply step copies the template into ~/.claude/agents/ or .claude/agents/ and edits your settings files itself, with a backup.

FindingSeverityWhat it catches
late-compactionwarnOver 30% of main-thread cost spent with more than 400K tokens in context.
subagents-on-top-modelwarnSubagents on Opus or Fable, once one type makes over 5% of all cost; the headline counts every type. Forks always run on the main model.
long-sessionsinfoSessions of 500+ turns, with their share of cost.
idle-cache-rebuildsinfoThe prompt cache rebuilt after it expired during a break.
images-read-repeatedlyinfoThe same image read three or more times.
  • It reads user, project and local settings files, ~/.claude.json (only the MCP server names, to count them) and your skill and agent folders. It cannot see managed (organisation) settings.
  • Whether a rule helps depends on your plan and workload. Findings explain the trade-off and link to the documentation; decide for yourself.
  • Deny rules in permissions.deny are best-effort for search tools and do not stop a read through Bash.
  • Session costs are in input-token equivalents: each model's API price ratios (cache reads 0.1 of input, 0.05 on Opus 5.5 and Sonnet 5.5, 0.025 on Fable 5.1; Haiku 5.5 prompts over 100K at 5x). Per-model prices are not applied, so the reports speak in shares, not dollars. Sessions on other machines and transcripts Claude Code has already cleaned up are not counted, except for days already in the history file.
npm test               # node's built-in test runner, no dependencies
npm run test:mod       # the hooks module's tests (claude plugin test)
claude plugin validate .

The landing page lives in site/ (cd site && npm install && npm run dev; npm run build prerenders it, node scripts/og.mjs redraws the social card). The demo is a real session recorded with asciinema; site/scripts/RECORDING.md says how to record it again.

Rules are pure functions over a snapshot (lib/rules.mjs); reading the disk lives in lib/collect.mjs and writing in lib/apply.mjs. Adding a rule means adding one entry and its test. Session analysis is in lib/sessions.mjs and lib/history.mjs, snapshots in lib/bench.mjs, the settings tokendoctor-set may change in lib/tunables.mjs, and the optimize tools in hooks/register.ts.

License

MIT

Source 1 files
hooks/register.ts 164 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Tools for /tokendoctor:optimize. Reading tools run without a prompt. A change runs only after the
4// person picks "Apply" in a dialog that shows it: a permission `ask` can be settled by the mode
5// (bypass, a -p run) without a person, so the dialog is the mod's own, and with no one to ask
6// ($.ui.ask rejects in -p) nothing changes. The tools wait behind ToolSearch, so they add nothing
7// to context until the optimize skill loads them.
8const PLUGIN = 'tokendoctor'
9const tool = <N extends string>(name: N) => `mcp__${PLUGIN}__${name}` as const
10const READS: string[] = [tool('metrics'), tool('settings'), tool('config_list')]
11const WRITES: string[] = [tool('config_set'), tool('set_setting')]
12
13const text = (value: unknown) => ({ result: typeof value === 'string' ? value : JSON.stringify(value, null, 1) })
14
15export const register: Register = on => {
16  on('session.start', async ($, e, next) => {
17    const result = await next(e)
18    await $.tool.register({
19      name: 'metrics',
20      description:
21        'Token metrics: the current session (context fill, rate limits, cost, context breakdown by category), ' +
22        'daily totals for the last `days` (default 14) and the current audit findings. Read-only.',
23      inputSchema: { type: 'object', properties: { days: { type: 'number' } } },
24    })
25    await $.tool.register({
26      name: 'settings',
27      description:
28        'Every setting tokendoctor may change, with its current merged value (unset ones included), ' +
29        'whether a change applies live or at the next session, and what it does. Read-only.',
30    })
31    await $.tool.register({
32      name: 'config_list',
33      description: 'The rows of the /config menu with their current values, kinds and options. Read-only.',
34    })
35    await $.tool.register({
36      name: 'config_set',
37      description: 'Change one /config row (key from config_list), live, as if the person did it in /config. Asks the person.',
38      inputSchema: { type: 'object', properties: { key: { type: 'string' }, value: {} }, required: ['key', 'value'] },
39    })
40    await $.tool.register({
41      name: 'set_setting',
42      description:
43        'Write one setting from the `settings` list to a settings file, with a backup. `value` omitted unsets it. ' +
44        'scope: user (default), project or local. Asks the person.',
45      inputSchema: {
46        type: 'object',
47        properties: { key: { type: 'string' }, value: {}, scope: { type: 'string', enum: ['user', 'project', 'local'] } },
48        required: ['key'],
49      },
50    })
51    return result
52  })
53
54  on('tool.check', async ($, e, next) => {
55    if (READS.includes(e.tool)) return { decision: 'allow' }
56    if (WRITES.includes(e.tool)) return { decision: 'allow' }
57    return next(e)
58  })
59
60  on('tool.call', { tool: tool('metrics') }, async ($, e) => {
61    const days = typeof e.days === 'number' ? e.days : 14
62    const usage = await $.session.usage({ breakdown: 'summary' })
63    const b = usage.context.breakdown
64    const session = {
65      tokens: usage.context.tokens,
66      window: usage.context.window,
67      compactAt: b?.autoCompactThreshold,
68      autoCompact: b?.isAutoCompactEnabled,
69      model: b?.model,
70      biggest: b?.categories
71        .filter(c => c.kind === 'used')
72        .sort((x, y) => y.tokens - x.tokens)
73        .slice(0, 6)
74        .map(c => ({ name: c.name, tokens: c.tokens })),
75      rateLimits: usage.rateLimits,
76      costUsd: usage.cost?.usd,
77    }
78    const trend = await bin($, 'tokendoctor-sessions', ['--by', 'day', '--days', String(days), '--json'])
79    const audit = await bin($, 'tokendoctor-audit', ['--json'])
80    let findings: unknown = audit
81    try {
82      findings = JSON.parse(audit).findings.map((f: { id: string; severity: string; title: string; fixes?: unknown }) => ({
83        id: f.id,
84        severity: f.severity,
85        title: f.title,
86        hasFix: Boolean(f.fixes),
87      }))
88    } catch {}
89    return text({ session, trend: safeJson(trend), findings })
90  })
91
92  on('tool.call', { tool: tool('settings') }, async ($) => {
93    const out = await bin($, 'tokendoctor-set', ['--list', '--json'])
94    const rows = safeJson(out)
95    if (!Array.isArray(rows)) return text(out)
96    const merged = (await $.settings.read()) as Record<string, unknown>
97    const env = (merged.env ?? {}) as Record<string, unknown>
98    const perModel = (merged.modelSettings ?? {}) as Record<string, Record<string, unknown>>
99    // A per-model row stays as the pattern for a model with nothing saved, and gets one row with the
100    // saved value for each model that has one (/effort saves the level there, not in effortLevel).
101    return text(
102      rows.flatMap((r: { key: string }) => {
103        const field = /^modelSettings\.<model id>\.(\w+)$/.exec(r.key)?.[1]
104        if (field) {
105          const saved = Object.entries(perModel).filter(([, m]) => m?.[field] !== undefined)
106          return [{ ...r, current: null }, ...saved.map(([model, m]) => ({ ...r, key: `modelSettings.${model}.${field}`, current: m[field] }))]
107        }
108        return [{ ...r, current: r.key.startsWith('env.') ? env[r.key.slice(4)] ?? null : merged[r.key] ?? null }]
109      }),
110    )
111  })
112
113  on('tool.call', { tool: tool('config_list') }, async ($) => {
114    const rows = await $.config.list()
115    return text(rows.map(r => ({ key: r.key, label: r.label, kind: r.kind, value: r.value, options: r.options })))
116  })
117
118  on('tool.call', { tool: tool('config_set') }, async ($, e) => {
119    const { key, value } = e as unknown as { key: string; value: never }
120    const row = (await $.config.list()).find(r => r.key === key)
121    if (!row) return text(`Not changed: /config has no row "${key}".`)
122    const change = `/config ${row.label}: ${JSON.stringify(row.value)} → ${JSON.stringify(value)} (applies now)`
123    if (!(await confirm($, change))) return text(`Not changed: the person did not approve (${change}).`)
124    const r = await $.config.set({ key, value })
125    return text(r.deny ? `Not changed: ${r.deny}` : `${key} is now ${JSON.stringify(r.value)} (live).`)
126  })
127
128  on('tool.call', { tool: tool('set_setting') }, async ($, e) => {
129    const { key, value, scope } = e as unknown as { key: string; value?: unknown; scope?: string }
130    const args = [key, ...(value === undefined ? ['--unset'] : [JSON.stringify(value)]), '--scope', scope ?? 'user']
131    const plan = await bin($, 'tokendoctor-set', args)
132    if (!plan.startsWith('Would change')) return text(plan)
133    if (!(await confirm($, plan.replace(/\n+Dry run.*$/s, '')))) return text(`Not changed: the person did not approve.\n${plan}`)
134    return text(await bin($, 'tokendoctor-set', [...args, '--write']))
135  })
136}
137
138// The person's yes, in the engine's own dialog. False when they decline, dismiss it, or no one can
139// be asked (a -p run).
140async function confirm($: EngineInterface, change: string) {
141  try {
142    const answer = await $.ui.ask(`tokendoctor wants to change a setting:\n${change}\nApply it?`, {
143      header: 'tokendoctor',
144      options: ['Apply', 'Cancel'],
145    })
146    return answer === 'Apply'
147  } catch {
148    return false
149  }
150}
151
152async function bin($: EngineInterface, name: string, args: string[]) {
153  const { exitCode, stdout, stderr } = await $.process.run(['node', `${$.plugin.root}/bin/${name}`, ...args], { timeoutMs: 120_000 })
154  return exitCode === 0 ? stdout : `${name} failed (${exitCode}): ${stderr || stdout}`
155}
156
157function safeJson(s: string): unknown {
158  try {
159    return JSON.parse(s)
160  } catch {
161    return s
162  }
163}
164