SLOPSHOPPER

leaks

Find where your Claude Code week went and fix it: /leaks ranks the avoidable spend in your transcripts with one-press fixes, and a strip above the prompt flags…

newpanebandcommandtoastprocess
★ 8v0.1.1MITupdated 2026-10-16davekiss/mods/leaks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leaks
│ ┃ Leaks ✕ › fix the failing auth test and add an audit log call │ ┃ LEAKS │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ─────────── ⎿ Read 6 lines │ ┃ leaks couldn't read your transcripts: JSON ⏺ Update(src/auth.ts) │ ┃ Parse error: Unexpected identifier "dev" ⎿ 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 │ │ › /leaks │ ⎿ leaks: leaks couldn't read your transcripts: JSON Parse error: U │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Leaks
LEAKS ─────────────────────────────────────────────────────── leaks couldn't read your transcripts: JSON Parse error: Unexpected identifier "dev"
README

leaks

A Claude Code mod that finds where your week went and fixes it.

  • /leaks opens a pane that ranks the avoidable spend in your last 7 days of transcripts (/leaks --days 30 for more), each leak with what it cost and the fix. A fix that's a setting or a CLAUDE.md line applies with one press (a).
  • The strip above the prompt flags a leak while it's happening: a context past 400k that every turn re-reads, or a cache about to expire (or already expired) on a big conversation, with what your next message will re-write. Press "compact now" to shrink it first.

What it finds

  • Sessions past the compact point. Every turn re-reads the whole conversation, so a turn at 800k costs four times one at 200k. Each session is replayed as if it had compacted at 400k, and the difference is the saving. Fix: "autoCompactWindow": 400000 in ~/.claude/settings.json.
  • Cache rebuilt after a break. Coming back after the cache expired (an hour for Claude Code's 1h cache, 5 minutes for the 5m one) re-writes the whole conversation at cache-write price. Fix: /clear or /compact before you carry on.
  • Read-only subagents on a big model. Agent types whose runs only read, priced again at Haiku rates. Fix: model: haiku in the agent's frontmatter.
  • Subagent cache expiring between steps. Subagents get a 5-minute cache, so one that waits longer for its next step re-writes its whole context. A 1-hour cache ("subagentPromptCacheTtl": "1h") turns those into reads but costs more per write, so it's flagged only when replaying your week with it comes out cheaper: typically an orchestrator whose subagents idle between steps.
  • Huge tool outputs carried along. A tool result of about 8k tokens or more that every later turn paid to re-read. Fix: a CLAUDE.md line to grep or read a range.

It reads ~/.claude/projects on your machine (every response's message.usage) and prices each call at the API rates in leaks.mjs. Nothing leaves your machine. Needs Node 22+.

Install

/plugin marketplace add davekiss/mods
/plugin install leaks@davekiss

Then start a new session and run /leaks. From a checkout: claude --plugin-dir ./leaks. The engine also runs on its own: node leaks.mjs (--days 30, --top 10, --json, --compact-at 300k).

Read it right

  • Dollars are API-equivalent. On a subscription they're your share of the limit, not a bill.
  • Savings are estimates and overlap a little: compacting earlier also shrinks the cache rebuilds. Compacting trades detail for cost, since the conversation becomes a summary.
  • Tool-output sizes are estimated at about 4 characters per token.

Develop

claude plugin test . runs the mod's tests, and node --test tests/engine.spec.mjs the engine's.

Source 2 files
hooks/register.tsx 236 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Leak, Live, Report } from '../types'
5
6const PANE = 'leaks'
7const RED = '#E4312B'
8const CACHE_TTL_MS = 60 * 60_000 // Claude Code writes the 1-hour cache
9const CACHE_WARN_MS = 50 * 60_000
10const MIN_REWRITE = 100_000 // a smaller context is cheap to re-write
11
12const report = atom({ plugin: 'leaks', key: 'report' } as const, null as Report | null)
13const isLoading = atom({ plugin: 'leaks', key: 'isLoading' } as const, false)
14const error = atom({ plugin: 'leaks', key: 'error' } as const, null as string | null)
15const applied = atom({ plugin: 'leaks', key: 'applied' } as const, [] as string[])
16const live = atom({ plugin: 'leaks', key: 'live' } as const, { tokens: 0, window: 0, week: null, lastTurnAt: null } as Live)
17const hidden = atom({ plugin: 'leaks', key: 'hidden' } as const, null as string | null)
18const selected = atom({ plugin: 'leaks', key: 'selected' } as const, 0)
19
20// $ per million tokens written to the 1-hour cache, by model.
21const CACHE_WRITE: [RegExp, number][] = [[/fable|mythos/, 20], [/opus/, 8], [/sonnet/, 4], [/haiku/, 2]]
22export const rewriteCost = (model: string, tokens: number) =>
23  ((CACHE_WRITE.find(([re]) => re.test(model))?.[1] ?? 8) * tokens) / 1e6
24
25export const k = (n: number) => `${Math.round(n / 1000)}k`
26export const usd = (n: number) => (n >= 10 ? `$${n.toFixed(0)}` : `$${n.toFixed(2)}`)
27
28export type Warning = { key: string; text: string; action: 'compact' }
29
30// The one leak happening right now, if any. The cache one wins: it is about to
31// cost a whole re-write, where a big context costs a little on every turn.
32export function warning(l: Live, now: number, warnAt: number, model = ''): Warning | null {
33  const idle = l.lastTurnAt === null ? 0 : now - l.lastTurnAt
34  if (l.tokens >= MIN_REWRITE && idle >= CACHE_WARN_MS) {
35    const cost = usd(rewriteCost(model, l.tokens))
36    return idle >= CACHE_TTL_MS
37      ? { key: `expired:${l.lastTurnAt}`, text: `cache expired ${Math.round((idle - CACHE_TTL_MS) / 60_000)}m ago · your next message re-writes ${k(l.tokens)} tokens (~${cost})`, action: 'compact' }
38      : { key: `expiring:${l.lastTurnAt}`, text: `cache expires in ${Math.ceil((CACHE_TTL_MS - idle) / 60_000)}m · then the next message re-writes ${k(l.tokens)} tokens (~${cost})`, action: 'compact' }
39  }
40  if (l.tokens >= warnAt) return { key: `context:${Math.floor(l.tokens / 100_000)}`, text: `context ${k(l.tokens)} · every turn re-reads all of it`, action: 'compact' }
41  return null
42}
43
44// An "apply:" line from the report, as a change to a file.
45export type Fix = { kind: 'setting'; key: string; value: number | string } | { kind: 'claude-md'; line: string } | { kind: 'manual' }
46export function parseApply(apply: string | undefined): Fix {
47  const setting = apply?.match(/^setting (\w+)=(\S+)$/)
48  if (setting) return { kind: 'setting', key: setting[1] as string, value: /^\d+$/.test(setting[2] as string) ? Number(setting[2]) : (setting[2] as string) }
49  const md = apply?.match(/^CLAUDE\.md "(.+)"$/)
50  if (md) return { kind: 'claude-md', line: md[1] as string }
51  return { kind: 'manual' }
52}
53
54// settings.json with one key set and every other key kept.
55export const withSetting = (text: string, key: string, value: unknown) =>
56  `${JSON.stringify({ ...(text.trim() ? JSON.parse(text) : {}), [key]: value }, null, 2)}\n`
57
58export const withLine = (text: string, line: string) =>
59  text.includes(line) ? text : `${text.replace(/\n*$/, '')}${text.trim() ? '\n\n' : ''}${line}\n`
60
61async function claudeDir($: EngineInterface) {
62  return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
63}
64
65async function readOr($: EngineInterface, path: string) {
66  return (await $.fs.exists(path)) ? $.fs.read(path) : ''
67}
68
69async function apply($: EngineInterface, leak: Leak) {
70  const fix = parseApply(leak.apply)
71  const dir = await claudeDir($)
72  if (fix.kind === 'setting') {
73    const path = `${dir}/settings.json`
74    await $.fs.write(path, withSetting(await readOr($, path), fix.key, fix.value))
75    $.ui.toast(`leaks: ${fix.key} is ${fix.value} in ~/.claude/settings.json, for new sessions`)
76  } else if (fix.kind === 'claude-md') {
77    const path = `${dir}/CLAUDE.md`
78    await $.fs.write(path, withLine(await readOr($, path), fix.line))
79    $.ui.toast('leaks: added the line to ~/.claude/CLAUDE.md')
80  } else return
81  await update($, applied, prev => [...prev, leak.apply as string])
82}
83
84async function scan($: EngineInterface, args: string) {
85  await update($, isLoading, () => true)
86  await update($, error, () => null)
87  const days = Number(args.match(/--days\s+(\d+)/)?.[1] ?? 7)
88  const ran = await $.process.run(['node', `${$.plugin.root}/leaks.mjs`, '--json', '--days', String(days)], { timeoutMs: 120_000 })
89  try {
90    if (ran.exitCode !== 0) throw new Error((ran.stderr || ran.stdout).trim().slice(0, 300))
91    const r = JSON.parse(ran.stdout) as Omit<Report, 'days' | 'at'>
92    await update($, report, () => ({ ...r, days, at: Date.now() }))
93    await update($, selected, () => 0)
94  } catch (err) {
95    await update($, error, () => `leaks couldn't read your transcripts: ${String((err as Error).message ?? err)}`)
96  }
97  await update($, isLoading, () => false)
98}
99
100async function measure($: EngineInterface, lastTurnAt?: number) {
101  const u = await $.session.usage()
102  const week = u.rateLimits.find(r => r.kind === 'seven_day')?.percentUsed ?? null
103  await update($, live, prev => {
104    const next = { tokens: u.context.tokens ?? 0, window: u.context.window, week, lastTurnAt: lastTurnAt ?? prev.lastTurnAt }
105    return next.tokens === prev.tokens && next.week === prev.week && next.lastTurnAt === prev.lastTurnAt ? prev : next
106  })
107}
108
109async function compact($: EngineInterface) {
110  try {
111    await $.session.compact()
112    await measure($)
113  } catch {
114    $.ui.toast('leaks: can\'t compact while a turn runs; try again when it finishes')
115  }
116}
117
118export const register: Register = (on, options) => {
119  const warnAt = Number(options.warn_at) > 0 ? Number(options.warn_at) : 400_000
120
121  on('session.start', async ($, e, next) => {
122    // A user's own /leaks (the old command file) refuses the name; the strip still runs. A session
123    // that started with that file keeps it until restarted, so only a file still on disk is worth a toast.
124    await $.command
125      .register({ name: 'leaks', description: 'Where your Claude Code week went, and one-press fixes (/leaks --days 30)' })
126      .catch(async () => {
127        const old = `${await claudeDir($)}/commands/leaks.md`
128        if (await $.fs.exists(old)) $.ui.toast(`leaks: /leaks is taken by ${old}; remove it to use the pane`)
129      })
130    await measure($)
131    // A minute's tick moves the cache countdown; nothing else changes while idle.
132    $.clock.every(60_000, () => measure($))
133    return next(e)
134  })
135
136  on('turn.complete', async ($, e, next) => {
137    const out = await next(e)
138    await measure($, await $.clock.now())
139    return out
140  })
141
142  on('command.run', { command: 'leaks' }, async ($, e) => {
143    await $.ui.open({ id: PANE, title: 'Leaks', focus: true })
144    await scan($, e.args)
145    const r = await read($, report)
146    return { text: r ? `${usd(r.leaks.reduce((s, l) => s + l.save, 0))} of ${usd(r.total)} avoidable in the last ${r.days} days` : ((await read($, error)) ?? 'leaks: no report') }
147  })
148
149  // The leak happening now, one row above the prompt.
150  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151    if (e.props.hasSurvey || e.props.isWorking) return next(e)
152    const w = warning(await read($, live), await $.clock.now(), warnAt, await $.session.model())
153    if (w === null || (await read($, hidden)) === w.key) return next(e)
154    const { Box, Text, Button } = $.ui.resolve(e)
155    const week = (await read($, live)).week
156    return (
157      <Box gap={2}>
158        <Text color={RED}>◆ leaks</Text>
159        <Text>{w.text}</Text>
160        {week !== null && <Text dimColor>week {Math.round(week)}% used</Text>}
161        <Button key="leaks-compact" plain label="compact now" onPress={() => compact($)} />
162        <Button key="leaks-hide" plain dimColor label="hide" onPress={() => update($, hidden, () => w.key)} />
163      </Box>
164    )
165  })
166
167  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
168    const { Box, Text, Button } = $.ui.resolve(e)
169    const r = await read($, report)
170    const done = await read($, applied)
171    const width = Math.max(30, e.props.bodyColumns)
172    const rule = <Text dimColor>{'─'.repeat(Math.max(1, width - 1))}</Text>
173    const header = (
174      <Box>
175        <Text bold>LEAKS  </Text>
176        {r && <Text dimColor>last {r.days} days · {r.calls.toLocaleString()} API calls</Text>}
177      </Box>
178    )
179    if (await read($, isLoading)) return <Box flexDirection="column">{header}{rule}<Text dimColor>Reading your transcripts…</Text></Box>
180    const err = await read($, error)
181    if (err || !r) return <Box flexDirection="column">{header}{rule}<Text>{err ?? 'Run /leaks to scan your transcripts.'}</Text></Box>
182
183    const avoidable = r.leaks.reduce((s, l) => s + l.save, 0)
184    const pct = (n: number) => (r.total ? Math.round((n / r.total) * 100) : 0)
185    const index = Math.min(await read($, selected), Math.max(0, r.leaks.length - 1))
186    const leak = r.leaks[index]
187    const top = r.leaks[0]?.save || 1
188    const pick = (i: number) => update($, selected, () => Math.max(0, Math.min(r.leaks.length - 1, i)))
189    const fix = parseApply(leak?.apply)
190    const isDone = leak?.apply !== undefined && done.includes(leak.apply)
191
192    return (
193      <Box flexDirection="column">
194        {header}
195        {rule}
196        <Box marginTop={1} gap={2}>
197          <Text bold>{usd(r.total)}</Text>
198          <Text dimColor>API-equivalent spent</Text>
199          <Text color={RED} bold>{usd(avoidable)} avoidable ({pct(avoidable)}%)</Text>
200        </Box>
201        <Text dimColor>
202          {pct(r.pastCompact)}% went to turns past {k(r.compactAt)} context
203          {r.autoCompactWindow ? ` · autoCompactWindow is ${k(r.autoCompactWindow)}` : ''}
204        </Text>
205        <Box flexDirection="column" marginY={1}>
206          {r.leaks.length === 0 && <Text>No leaks found.</Text>}
207          {r.leaks.slice(0, 8).map((l, i) => (
208            <Box key={`leak:${i}`}>
209              <Text color={RED}>{i === index ? '▌ ' : '  '}</Text>
210              <Text color={RED} bold={i === index}>{usd(l.save).padStart(6)} </Text>
211              <Text color={RED}>{'█'.repeat(Math.max(1, Math.round((l.save / top) * 12))).padEnd(13)}</Text>
212              <Button key={`row:${i}`} plain dimColor={i !== index} label={l.title} {...(i === index ? { autoFocus: true as const } : {})} onPress={() => pick(i)} />
213            </Box>
214          ))}
215        </Box>
216        {leak && (
217          <Box flexDirection="column">
218            {rule}
219            <Box marginTop={1}><Text wrap="wrap">{leak.detail}</Text></Box>
220            <Box marginTop={1}><Text color="green" wrap="wrap">→ {leak.fix}</Text></Box>
221            <Box marginTop={1} gap={3}>
222              {fix.kind !== 'manual' && (isDone
223                ? <Text key="applied" color="green">✓ applied</Text>
224                : <Button key="apply" hotkey="a" label={fix.kind === 'setting' ? `apply: ${fix.key} ${typeof fix.value === 'number' ? k(fix.value) : fix.value}` : 'apply: add to CLAUDE.md'} onPress={() => apply($, leak)} />)}
225              <Button key="prev" hotkey="k" plain label="up" onPress={() => pick(index - 1)} />
226              <Button key="next" hotkey="j" plain label="down" onPress={() => pick(index + 1)} />
227              <Button key="rescan" hotkey="r" plain dimColor label="rescan" onPress={() => scan($, `--days ${r.days}`)} />
228            </Box>
229          </Box>
230        )}
231        <Box marginTop={1}><Text dimColor wrap="wrap">Estimates at API prices; on a plan they're your share of the limit. Nothing leaves your machine.</Text></Box>
232      </Box>
233    )
234  })
235}
236
types/index.d.ts 14 lines
1// One row of the report, as leaks.mjs --json writes it.
2export type Leak = { kind: 'context' | 'cache' | 'subagent' | 'output'; save: number; title: string; detail: string; fix: string; apply?: string }
3
4export type Report = { total: number; pastCompact: number; compactAt: number; autoCompactWindow: number | null; calls: number; leaks: Leak[]; days: number; at: number }
5
6// What the strip above the prompt knows about the session right now.
7export type Live = { tokens: number; window: number; week: number | null; lastTurnAt: number | null }
8
9declare module 'claude-code' {
10  interface PluginState {
11    leaks: { report: Report | null; isLoading: boolean; error: string | null; applied: string[]; live: Live; hidden: string | null; selected: number }
12  }
13}
14