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…

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)."autoCompactWindow": 400000 in ~/.claude/settings.json./clear or /compact before you carry on.model: haiku in the agent's frontmatter."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.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+.
/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).
claude plugin test . runs the mod's tests, and node --test tests/engine.spec.mjs the engine's.
hooks/register.tsx 236 lines1import { 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}
236types/index.d.ts 14 lines1// 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