A terminal cost dashboard (/burn): spend by day, project, model, activity, tools, shell commands and MCP servers, read from your local Claude Code transcripts…

A cost dashboard inside Claude Code. /burn opens a pane that shows where your tokens went, read from the session transcripts Claude Code already keeps on your machine:
[ Today ] [ 7 Days ] [ 30 Days ] [ This Month ] [ 6 Months ] [ Lifetime ] [ r refresh ]
╭──────────────────────────────────────────────────────────────────────────────────────╮
│ burn-rate 7 Days 2026-10-02 → 2026-10-08 · est. at list price │
│ $36.75 302 calls · 1 sessions · 91% cache hit │
│ tokens in 591 · out 141k · cached 64M · written 6.2M │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭ Daily Activity ─────────────────────────╮ ╭ By Project ─────────────────────────────╮
│ 2026-10-08 ██████████████▊ $30.35 260 │ │ webapp ███████████ $36.75 $36.75 1 │
│ 2026-10-07 ███▏ $6.40 42 │ │ │
│ Showing 1–2 of 2 days │ │ │
╰─────────────────────────────────────────╯ ╰─────────────────────────────────────────╯
By Model · By Activity · Core Tools · Shell Commands · MCP Servers …
It also pins a one-line status under the prompt after each turn, such as 🔥 $12.40 today.
Inspired by codeburn. This mod is written from scratch and shares no code with it.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install burn-rate@mod-squad
/burn opens the dashboard (focused; Esc closes it) and refreshes the numbers in the background. You can also pass a range: /burn today, 7d, 30d, month, 6m or lifetime.t Today, 7 7 Days, 3 30 Days, m This Month, 6 6 Months, l Lifetime, r refresh. Tab and Enter work too. The range you pick is remembered.defaultRange (one of today, 7d, 30d, month, 6m, lifetime; default 7d) is the range the pane opens on until you pick another.The panels:
| Panel | What it shows |
|---|---|
| Header | Total cost, API calls, sessions, cache-hit % (cache reads ÷ all prompt tokens), and tokens in, out, cached (read) and written (cache writes). |
| Daily Activity | One row per day, newest first. Each row has a bar coloured by its cost relative to the costliest day, the cost and the number of calls. The panel fits the pane's height and says "Showing 1–N of M". |
| By Project | The project is the last folder of the transcript's cwd. Shows cost, average cost per session and the number of sessions. |
| By Model | Cost, cache-hit % and calls for each model. A model with no price is listed as (unpriced) <id> and costs $0. |
| By Activity | Cost and turns for each kind of work (rules below). |
| Core Tools | Calls to each built-in tool. |
| Shell Commands | The first word of each Bash command. Leading sudo, env X=y, X=y, cd dir && and cd dir; are stripped, so cd app && npm test counts as npm. |
| MCP Servers | mcp__<server>__* calls, grouped by server. |
From 100 columns up the panels sit two to a row; narrower panes stack them in one column. Names are cut to fit.
Costs are API list prices applied to the token counts in your transcripts. Batch, priority and negotiated pricing are not applied. On a Claude Max or Pro subscription you don't pay per token, so the figure is what the same usage would have cost on the API, not a bill.
Prices are in USD per million tokens. Model ids match by prefix, and the longest prefix wins:
| Model | Input | Output | Cache read |
|---|---|---|---|
| claude-fable-5-1, claude-fable-5, claude-mythos-* | 10 | 50 | 0.25 |
| claude-opus-5-5 | 4 | 20 | 0.20 |
| claude-opus-5, -4-8, -4-7, -4-6 | 5 | 25 | 0.50 |
| claude-sonnet-5-5 | 2 | 10 | 0.10 |
| claude-sonnet-5 | 2 | 10 | 0.20 |
| claude-sonnet-4-6, -4-5 | 3 | 15 | 0.30 |
| claude-haiku-5-5 (prompt ≤ 100k) | 0.10 | 0.50 | 0.01 |
| claude-haiku-5-5 (prompt > 100k) | 0.50 | 2.50 | 0.05 |
| claude-haiku-4-5 | 1 | 5 | 0.10 |
Cache writes cost 1.25× input for the 5-minute TTL and 2× input for the 1-hour TTL. The split comes from each response's usage.cache_creation; when a response has no split, its writes are priced at the 5-minute rate. The table lives in hooks/pricing.ts.
The data is every *.jsonl under ~/.claude/projects/ (and under $CLAUDE_CONFIG_DIR/projects/ if that is set), including the subagent transcripts in <session>/subagents/. Subagent work counts toward cost.
assistant line carries message.model, message.usage and message.id. A streamed response is written as several lines that share one message.id (and requestId), and each line repeats the usage. burn-rate counts each id once. If a later line for the same id reports more tokens (the last line has the final output_tokens), only the difference is added. Lines with zero usage, <synthetic> model lines and API-error lines are skipped.timestamp. The UTC offset comes from the host's date +%z.sessionIds with calls in the range.Each turn is classified from its prompt's words and the tools it used. The first rule that matches wins:
npm test, pytest, go test, cargo test, vitest, …), or the prompt mentions tests: Testing..md, .mdx, .rst, .txt, README), or the prompt is about docs or a README: Docs.Scans never run inside a hook. /burn, the r button, and a refresh 5 seconds after session start (only once you've used /burn before) each start a scan on a $.clock timer. The pane says "scanning…" while one runs.
$.store with the file's size, mtime and the byte offset parsed so far. A file whose size and mtime haven't changed is not read again. A file that grew is parsed only from the stored offset.$.fs.read, which refuses anything over 4 MiB. Larger files are read 3 MB at a time from the stored offset with sh -c 'tail -c +N file | head -c M' through $.process.run. A single line longer than a chunk is skipped.cleanupPeriodDays) stay in the store, so Lifetime keeps them.turn.complete's usage) to the last scan's total for today, so it costs nothing to compute.Limits:
sh, tail and head on the host. They are present on macOS and Linux; on Windows such files are skipped, and the pane says how many.$.store holds 4 MiB of JSON in all, which is about 2 KB per transcript. If a very large history fills it, the aggregates that don't fit are kept in memory for the session but not saved, so the next session parses those files again.claude plugin validate mods/burn-rate
cd mods/burn-rate && claude plugin test .
The tests build synthetic transcripts in memory and mock $.fs, $.store, $.clock, $.env and $.process. No real transcript data is used.
hooks/register.tsx 365 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Range, ScanStatus, View } from '../types'
5import { RANGES, RANGE_HOTKEY, RANGE_LABEL, aggregateAll, isRange } from './aggregate'
6import { COLORS, fmtCost, headerLines, layout } from './layout'
7import type { Line } from './layout'
8import { dayKey, emptyFile, indexAtByte, parseText } from './parse'
9import type { FileState } from './parse'
10import { costOf } from './pricing'
11
12const PANE = 'burn-rate'
13/** Files up to this size are read whole with $.fs.read (which refuses anything over 4 MiB). */
14const FS_READ_MAX = 4_000_000
15/** Larger files are read this many bytes at a time through `tail | head`. */
16const CHUNK = 3_000_000
17/** Lists kept per view (the pane shows fewer). */
18const KEEP = 40
19const STORE_PREFIX = 'f:'
20
21const IDLE: ScanStatus = { isScanning: false, done: 0, total: 0, parsed: 0, skipped: 0, finishedAt: 0 }
22
23const rangeRef = atom({ plugin: 'burn-rate', key: 'range' } as const, null as Range | null)
24const viewsRef = atom({ plugin: 'burn-rate', key: 'views' } as const, null as Record<Range, View> | null)
25const statusRef = atom({ plugin: 'burn-rate', key: 'status' } as const, IDLE)
26const sinceRef = atom({ plugin: 'burn-rate', key: 'sinceScan' } as const, 0)
27
28type Options = { defaultRange?: string }
29type Found = { path: string; size: number; mtime: number }
30type Summary = { day: string; cost: number; tz: number }
31
32// Parsed aggregates by path, so a scan in this module's life re-reads nothing unchanged.
33// A hot reload empties it; $.store holds the same aggregates across sessions.
34const memory = new Map<string, FileState>()
35let isBusy = false
36
37/** Minutes east of UTC, from the host's `date` (the plugin's own clock may run in UTC). */
38async function tzOffset($: EngineInterface): Promise<number> {
39 try {
40 const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 })
41 const m = /^([+-])(\d{2})(\d{2})/.exec(r.stdout.trim())
42 if (m) return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
43 } catch {
44 // No process (desktop sandbox, tests): fall back to the environment's own idea.
45 }
46 return -new Date().getTimezoneOffset()
47}
48
49/** The Claude config folders whose projects/ hold transcripts. */
50async function configRoots($: EngineInterface): Promise<string[]> {
51 const roots: string[] = []
52 const configured = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
53 if (configured) for (const p of configured.split(',')) if (p.trim() !== '') roots.push(p.trim())
54 const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined))
55 if (home) roots.push(`${home.replace(/[\\/]+$/, '')}/.claude`)
56 return [...new Set(roots.map(r => r.replace(/[\\/]+$/, '')))]
57}
58
59/** Every *.jsonl under `dir`, subagent folders included, `depth` levels down. */
60async function walk($: EngineInterface, dir: string, depth: number, out: Found[]) {
61 const entries = await $.fs.list(dir).catch(() => [])
62 for (const en of entries) {
63 const path = `${dir}/${en.name}`
64 if (en.kind === 'file' && en.name.endsWith('.jsonl')) out.push({ path, size: en.size, mtime: en.mtimeMs })
65 else if (en.kind === 'dir' && depth > 0 && en.name !== 'tool-results') await walk($, path, depth - 1, out)
66 }
67}
68
69async function discover($: EngineInterface): Promise<Found[]> {
70 const out: Found[] = []
71 for (const root of await configRoots($)) await walk($, `${root}/projects`, 3, out)
72 const seen = new Set<string>()
73 return out.filter(f => (seen.has(f.path) ? false : (seen.add(f.path), true)))
74}
75
76/** `count` bytes of `path` from byte `from`, through the shell (for files $.fs.read refuses). */
77async function readChunk($: EngineInterface, path: string, from: number, count: number): Promise<string> {
78 const r = await $.process.run(
79 ['sh', '-c', 'tail -c +"$1" "$2" | head -c "$3"', 'burn-rate', String(from + 1), path, String(count)],
80 { timeoutMs: 30_000 },
81 )
82 return r.stdout
83}
84
85/** Bytes in the line starting at `from` (for a single line longer than a chunk, which is skipped). */
86async function lineLength($: EngineInterface, path: string, from: number): Promise<number> {
87 const r = await $.process.run(['sh', '-c', 'tail -c +"$1" "$2" | head -n 1 | wc -c', 'burn-rate', String(from + 1), path], {
88 timeoutMs: 30_000,
89 })
90 return Number(r.stdout.trim()) || 0
91}
92
93async function storedState($: EngineInterface, path: string): Promise<FileState | undefined> {
94 const held = memory.get(path)
95 if (held !== undefined) return held
96 const raw = (await $.store.get(STORE_PREFIX + path).catch(() => undefined)) as FileState | undefined
97 return raw && raw.v === 2 ? raw : undefined
98}
99
100/** Brings one file's aggregate up to date, reading only what was appended since last time. */
101async function scanFile($: EngineInterface, f: Found, tz: number): Promise<{ state: FileState; isParsed: boolean }> {
102 const prev = await storedState($, f.path)
103 if (prev !== undefined && prev.size === f.size && prev.mtime === f.mtime) return { state: prev, isParsed: false }
104
105 // Transcripts are append-only: carry on from where the last parse stopped, unless the file shrank.
106 const state: FileState =
107 prev !== undefined && f.size >= prev.off ? (JSON.parse(JSON.stringify(prev)) as FileState) : emptyFile()
108
109 if (f.size <= FS_READ_MAX) {
110 const text = await $.fs.read(f.path)
111 state.off += parseText(state, text.slice(indexAtByte(text, state.off)), tz)
112 } else {
113 for (let guard = 0; state.off < f.size && guard < 10_000; guard++) {
114 const text = await readChunk($, f.path, state.off, CHUNK)
115 if (text === '') break
116 const used = parseText(state, text, tz)
117 if (used > 0) state.off += used
118 else if (text.length < CHUNK && !text.includes('\n')) break // a partial last line still being written
119 else state.off += Math.max(1, await lineLength($, f.path, state.off))
120 }
121 }
122 state.size = f.size
123 state.mtime = f.mtime
124 memory.set(f.path, state)
125 await $.store.set(STORE_PREFIX + f.path, state).catch(() => undefined)
126 return { state, isParsed: true }
127}
128
129function trimView(v: View): View {
130 return {
131 ...v,
132 projects: v.projects.slice(0, KEEP),
133 models: v.models.slice(0, KEEP),
134 tools: v.tools.slice(0, KEEP),
135 shell: v.shell.slice(0, KEEP),
136 mcp: v.mcp.slice(0, KEEP),
137 }
138}
139
140function statusLine(todayCost: number): string {
141 return `🔥 ${fmtCost(todayCost)} today`
142}
143
144/** Scans every transcript (incrementally) and publishes all ranges' views. Runs off any hook's clock. */
145async function runScan($: EngineInterface) {
146 if (isBusy) return
147 isBusy = true
148 try {
149 await update($, statusRef, s => ({ ...(s ?? IDLE), isScanning: true, done: 0, total: 0, error: undefined }))
150 const tz = await tzOffset($)
151 const found = await discover($)
152 await update($, statusRef, s => ({ ...(s ?? IDLE), total: found.length }))
153
154 const states: FileState[] = []
155 let parsed = 0
156 let skipped = 0
157 for (let i = 0; i < found.length; i++) {
158 const f = found[i]
159 if (f === undefined) continue
160 try {
161 const r = await scanFile($, f, tz)
162 states.push(r.state)
163 if (r.isParsed) parsed++
164 } catch {
165 skipped++
166 const prev = await storedState($, f.path)
167 if (prev !== undefined) states.push(prev)
168 }
169 if (i % 10 === 9) await update($, statusRef, s => ({ ...(s ?? IDLE), done: i + 1 }))
170 }
171
172 // Transcripts Claude Code has since cleaned up still count: their aggregates stay in the store.
173 const live = new Set(found.map(f => f.path))
174 for (const key of await $.store.keys().catch(() => [] as string[])) {
175 if (!key.startsWith(STORE_PREFIX) || live.has(key.slice(STORE_PREFIX.length))) continue
176 const kept = await storedState($, key.slice(STORE_PREFIX.length))
177 if (kept !== undefined) states.push(kept)
178 }
179
180 const today = dayKey(await $.clock.now(), tz)
181 const all = aggregateAll(states, today)
182 const views = Object.fromEntries(RANGES.map(r => [r, trimView(all[r])])) as Record<Range, View>
183 const finishedAt = await $.clock.now()
184 // No transcripts at all: leave the views empty so the pane says so.
185 await update($, viewsRef, () => (states.length === 0 ? null : views))
186 await update($, sinceRef, () => 0)
187 await update($, statusRef, () => ({ isScanning: false, done: found.length, total: found.length, parsed, skipped, finishedAt }))
188 const summary: Summary = { day: today, cost: views.today.cost, tz }
189 await $.store.set('summary', summary).catch(() => undefined)
190 $.ui.status(statusLine(views.today.cost))
191 } catch (err) {
192 const error = err instanceof Error ? err.message : String(err)
193 await update($, statusRef, s => ({ ...(s ?? IDLE), isScanning: false, error })).catch(() => undefined)
194 } finally {
195 isBusy = false
196 }
197}
198
199/** Starts a scan on the clock, so no hook waits for it. */
200function startScan($: EngineInterface) {
201 $.clock.after(1, () => {
202 void runScan($)
203 })
204}
205
206async function setRange($: EngineInterface, range: Range) {
207 await update($, rangeRef, () => range)
208 await $.store.set('range', range).catch(() => undefined)
209}
210
211const OPEN = { id: PANE, title: 'burn-rate', focus: true, closeOnEscape: true, rows: 40, columns: 110 } as const
212
213export const register: Register = (on, options) => {
214 const opts = (options ?? {}) as Options
215 const defaultRange: Range = isRange(opts.defaultRange) ? opts.defaultRange : '7d'
216
217 on('session.start', async ($, e, next) => {
218 await $.command.register({
219 name: 'burn',
220 description: 'burn-rate: cost dashboard from your local Claude Code transcripts (est. at list price)',
221 argumentHint: '[today | 7d | 30d | month | 6m | lifetime]',
222 immediate: true,
223 })
224 const saved = await $.store.get('range')
225 if (isRange(saved)) await update($, rangeRef, () => saved)
226 // Someone who has used the dashboard gets a fresh total in the status line shortly after start.
227 const summary = (await $.store.get('summary')) as Summary | undefined
228 if (summary !== undefined) $.clock.after(5_000, () => void runScan($))
229 return next(e)
230 }).catch(($, e, next) => next(e))
231
232 on('command.run', { command: 'burn' }, async ($, e) => {
233 const arg = e.args.trim().toLowerCase()
234 if (isRange(arg)) await setRange($, arg)
235 await $.ui.open(OPEN).catch(() => undefined)
236 startScan($)
237 const views = await read($, viewsRef)
238 const range = (await read($, rangeRef)) ?? defaultRange
239 const v = views?.[range]
240 return {
241 text:
242 v === undefined
243 ? 'burn-rate: scanning your transcripts…'
244 : `burn-rate (${RANGE_LABEL[range]}): ${fmtCost(v.cost)} · ${v.calls} calls · ${v.sessions} sessions (est. at list price; refreshing)`,
245 }
246 })
247
248 // Cheap: the last scan's total for today plus what each finished turn cost since.
249 on('turn.complete', async ($, e, next) => {
250 const result = await next(e)
251 const u = e.usage
252 if (u !== undefined) {
253 const cost = costOf(u.model, {
254 input: u.input_tokens,
255 output: u.output_tokens,
256 cacheRead: u.cache_read_input_tokens,
257 cacheWrite5m: u.cache_creation_input_tokens,
258 cacheWrite1h: 0,
259 })
260 await update($, sinceRef, n => (n ?? 0) + cost)
261 }
262 const since = await read($, sinceRef)
263 const summary = (await $.store.get('summary')) as Summary | undefined
264 const today = summary === undefined ? '' : dayKey(await $.clock.now(), summary.tz)
265 $.ui.status(
266 summary !== undefined && summary.day === today ? statusLine(summary.cost + since) : `🔥 ${fmtCost(since)} this session`,
267 )
268 return result
269 }).catch(($, e, next) => next(e))
270
271 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
272 const { Box, Text, Button } = $.ui.resolve(e)
273 const range = (await read($, rangeRef)) ?? defaultRange
274 const views = await read($, viewsRef)
275 const status = await read($, statusRef)
276 const cols = Math.max(20, e.props.bodyColumns)
277 const bodyRows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30
278 const view = views?.[range]
279 const inner = Math.max(10, cols - 4)
280
281 const line = (l: Line) => (
282 <Box>
283 {l.map(s => (
284 <Text color={s.color} bold={s.bold} dimColor={s.dim} wrap="truncate">
285 {s.text}
286 </Text>
287 ))}
288 </Box>
289 )
290
291 const progress = status.isScanning
292 ? `scanning… ${status.total > 0 ? `${status.done}/${status.total} files` : ''}`
293 : status.error !== undefined
294 ? `scan failed: ${status.error}`
295 : status.skipped > 0
296 ? `${status.skipped} file(s) could not be read`
297 : ''
298
299 const top = (
300 <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={cols}>
301 {RANGES.map(r => (
302 <Button
303 key={`range-${r}`}
304 label={RANGE_LABEL[r]}
305 hotkey={RANGE_HOTKEY[r]}
306 variant={r === range ? 'primary' : 'secondary'}
307 onPress={() => setRange($, r)}
308 />
309 ))}
310 <Button key="refresh" label="r refresh" hotkey="r" dimColor onPress={() => startScan($)} />
311 </Box>
312 )
313
314 if (view === undefined) {
315 const msg = status.isScanning
316 ? 'scanning… reading ~/.claude/projects'
317 : status.finishedAt > 0
318 ? 'No transcripts found under ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects).'
319 : 'No data yet: press r to scan.'
320 return (
321 <Box flexDirection="column" width={cols}>
322 {top}
323 <Box borderStyle="round" borderColor={COLORS.header} paddingX={1} flexDirection="column" width={cols}>
324 <Text bold color={COLORS.header}>
325 burn-rate
326 </Text>
327 <Text dimColor wrap="truncate">
328 {msg}
329 </Text>
330 {progress !== '' && !status.isScanning && <Text dimColor>{progress}</Text>}
331 </Box>
332 </Box>
333 )
334 }
335
336 const grid = layout(view, cols, bodyRows)
337 return (
338 <Box flexDirection="column" width={cols}>
339 {top}
340 <Box borderStyle="round" borderColor={COLORS.header} paddingX={1} flexDirection="column" width={cols}>
341 {headerLines(view, RANGE_LABEL[range], inner).map(line)}
342 {view.calls === 0 && <Text dimColor>No usage in this range.</Text>}
343 {progress !== '' && (
344 <Text dimColor wrap="truncate">
345 {progress}
346 </Text>
347 )}
348 </Box>
349 {grid.rows.map(panels => (
350 <Box flexDirection="row" columnGap={1} width={cols}>
351 {panels.map(p => (
352 <Box borderStyle="round" borderColor={p.color} paddingX={1} flexDirection="column" width={p.width}>
353 <Text bold color={p.color}>
354 {p.title}
355 </Text>
356 {p.lines.map(line)}
357 </Box>
358 ))}
359 </Box>
360 ))}
361 </Box>
362 )
363 })
364}
365hooks/aggregate.ts 192 lines1// Folds per-file aggregates into what the dashboard shows for one date range.
2
3import { CATEGORIES, classify } from './classify'
4import type { Category } from './classify'
5import type { FileState } from './parse'
6import { modelLabel } from './pricing'
7import type { Range, View } from '../types'
8
9export type { Range, View }
10
11export const RANGES = ['today', '7d', '30d', 'month', '6m', 'lifetime'] as const satisfies readonly Range[]
12
13export const RANGE_LABEL: Readonly<Record<Range, string>> = {
14 today: 'Today',
15 '7d': '7 Days',
16 '30d': '30 Days',
17 month: 'This Month',
18 '6m': '6 Months',
19 lifetime: 'Lifetime',
20}
21
22export const RANGE_HOTKEY: Readonly<Record<Range, string>> = {
23 today: 't',
24 '7d': '7',
25 '30d': '3',
26 month: 'm',
27 '6m': '6',
28 lifetime: 'l',
29}
30
31export function isRange(v: unknown): v is Range {
32 return typeof v === 'string' && (RANGES as readonly string[]).includes(v)
33}
34
35function shiftDay(key: string, days: number): string {
36 const t = Date.parse(`${key}T00:00:00Z`) + days * 86_400_000
37 return new Date(t).toISOString().slice(0, 10)
38}
39
40/** The first day a range covers, given today's YYYY-MM-DD. */
41export function rangeStart(range: Range, today: string): string {
42 switch (range) {
43 case 'today':
44 return today
45 case '7d':
46 return shiftDay(today, -6)
47 case '30d':
48 return shiftDay(today, -29)
49 case 'month':
50 return `${today.slice(0, 7)}-01`
51 case '6m': {
52 const y = Number(today.slice(0, 4))
53 const m = Number(today.slice(5, 7)) - 6
54 const yy = m <= 0 ? y - 1 : y
55 const mm = m <= 0 ? m + 12 : m
56 const last = new Date(Date.UTC(yy, mm, 0)).getUTCDate()
57 const dd = Math.min(Number(today.slice(8, 10)), last)
58 return shiftDay(`${yy}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`, 1)
59 }
60 case 'lifetime':
61 return ''
62 }
63}
64
65function add(map: Map<string, number>, key: string, by: number) {
66 map.set(key, (map.get(key) ?? 0) + by)
67}
68
69function counts(map: Map<string, number>) {
70 return [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
71}
72
73/** Everything the dashboard shows for `range`, over every file's aggregate. */
74export function aggregate(files: readonly FileState[], range: Range, today: string): View {
75 const from = rangeStart(range, today)
76 const inRange = (d: string) => d >= from && d <= today
77 const view: View = {
78 range,
79 from,
80 to: today,
81 cost: 0,
82 calls: 0,
83 sessions: 0,
84 input: 0,
85 output: 0,
86 cacheRead: 0,
87 cacheWrite: 0,
88 cacheHit: 0,
89 daily: [],
90 projects: [],
91 models: [],
92 activities: [],
93 tools: [],
94 shell: [],
95 mcp: [],
96 }
97 const daily = new Map<string, { cost: number; calls: number }>()
98 const projects = new Map<string, { cost: number; sessions: Set<string> }>()
99 const models = new Map<string, { cost: number; calls: number; read: number; prompt: number }>()
100 const acts = new Map<Category, { cost: number; turns: number }>()
101 const tools = new Map<string, number>()
102 const shell = new Map<string, number>()
103 const mcp = new Map<string, number>()
104 const sessions = new Set<string>()
105
106 files.forEach((f, i) => {
107 const sid = f.sid ?? `file-${i}`
108 for (const [dk, d] of Object.entries(f.days)) {
109 if (!inRange(dk)) continue
110 let dayCost = 0
111 let dayCalls = 0
112 for (const [model, r] of Object.entries(d.m)) {
113 const [calls, input, output, read, w5, w1, cost] = r
114 dayCost += cost
115 dayCalls += calls
116 view.input += input
117 view.output += output
118 view.cacheRead += read
119 view.cacheWrite += w5 + w1
120 const label = modelLabel(model)
121 const m = models.get(label) ?? { cost: 0, calls: 0, read: 0, prompt: 0 }
122 m.cost += cost
123 m.calls += calls
124 m.read += read
125 m.prompt += input + read + w5 + w1
126 models.set(label, m)
127 }
128 if (dayCalls > 0 || Object.keys(d.t).length + Object.keys(d.x).length > 0) sessions.add(sid)
129 const dd = daily.get(dk) ?? { cost: 0, calls: 0 }
130 dd.cost += dayCost
131 dd.calls += dayCalls
132 daily.set(dk, dd)
133 if (dayCalls > 0) {
134 const name = d.p ?? f.proj ?? '(unknown)'
135 const p = projects.get(name) ?? { cost: 0, sessions: new Set<string>() }
136 p.cost += dayCost
137 p.sessions.add(sid)
138 projects.set(name, p)
139 }
140 for (const [cat, [cost, turns]] of Object.entries(d.a) as [Category, [number, number]][]) {
141 const a = acts.get(cat) ?? { cost: 0, turns: 0 }
142 a.cost += cost
143 a.turns += turns
144 acts.set(cat, a)
145 }
146 for (const [k, n] of Object.entries(d.t)) add(tools, k, n)
147 for (const [k, n] of Object.entries(d.s)) add(shell, k, n)
148 for (const [k, n] of Object.entries(d.x)) add(mcp, k, n)
149 view.cost += dayCost
150 view.calls += dayCalls
151 }
152 // The turn still running (or the last one of the file) counts too.
153 const t = f.turn
154 if (t !== null && t.n > 0 && inRange(t.day)) {
155 const cat = classify(t.s)
156 const a = acts.get(cat) ?? { cost: 0, turns: 0 }
157 a.cost += t.c
158 a.turns += 1
159 acts.set(cat, a)
160 }
161 })
162
163 const prompt = view.input + view.cacheRead + view.cacheWrite
164 view.cacheHit = prompt > 0 ? view.cacheRead / prompt : 0
165 view.sessions = sessions.size
166 view.daily = [...daily]
167 .filter(([, d]) => d.calls > 0)
168 .map(([day, d]) => ({ day, ...d }))
169 .sort((a, b) => (a.day < b.day ? 1 : -1))
170 view.projects = [...projects]
171 .map(([name, p]) => ({ name, cost: p.cost, sessions: p.sessions.size, perSession: p.cost / Math.max(1, p.sessions.size) }))
172 .sort((a, b) => b.cost - a.cost)
173 view.models = [...models]
174 .map(([name, m]) => ({ name, cost: m.cost, calls: m.calls, cacheHit: m.prompt > 0 ? m.read / m.prompt : 0 }))
175 .sort((a, b) => b.cost - a.cost || b.calls - a.calls)
176 view.activities = CATEGORIES.flatMap(name => {
177 const a = acts.get(name)
178 return a === undefined || a.turns === 0 ? [] : [{ name, cost: a.cost, turns: a.turns }]
179 }).sort((a, b) => b.cost - a.cost)
180 view.tools = counts(tools)
181 view.shell = counts(shell)
182 view.mcp = counts(mcp)
183 return view
184}
185
186/** Every range at once, so switching is instant. */
187export function aggregateAll(files: readonly FileState[], today: string): Record<Range, View> {
188 const out = {} as Record<Range, View>
189 for (const r of RANGES) out[r] = aggregate(files, r, today)
190 return out
191}
192hooks/layout.ts 272 lines1// Turns a View into panels of plain text segments sized to the pane: no elements here, so it is testable as data.
2
3import type { View } from './aggregate'
4
5export type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
6export type Line = Seg[]
7export type Panel = { id: string; title: string; color: string; width: number; lines: Line[] }
8export type Layout = { twoColumns: boolean; rows: Panel[][] }
9
10export const COLORS = {
11 header: '#eab308',
12 daily: '#06b6d4',
13 project: '#a855f7',
14 model: '#f59e0b',
15 activity: '#22c55e',
16 tools: '#3b82f6',
17 shell: '#ec4899',
18 mcp: '#14b8a6',
19 cost: '#facc15',
20} as const
21
22/** Columns from which two panels sit side by side. */
23export const TWO_COLUMN_MIN = 100
24/** Rows each list panel shows besides Daily Activity. */
25export const LIST_ROWS = 8
26const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
27
28export function fmtCost(n: number): string {
29 if (n >= 10_000) return `$${Math.round(n).toLocaleString('en-US')}`
30 if (n >= 1000) return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
31 return `$${n.toFixed(2)}`
32}
33
34export function fmtCount(n: number): string {
35 return Math.round(n).toLocaleString('en-US')
36}
37
38export function fmtTokens(n: number): string {
39 if (n >= 1e9) return `${trim(n / 1e9)}B`
40 if (n >= 1e6) return `${trim(n / 1e6)}M`
41 if (n >= 1e3) return `${trim(n / 1e3)}k`
42 return String(Math.round(n))
43}
44
45function trim(x: number): string {
46 return x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, '')
47}
48
49export function fmtPct(f: number): string {
50 return `${Math.round(f * 100)}%`
51}
52
53/** A bar `width` cells wide filled to `frac` (0..1) with eighth-block precision. */
54export function bar(frac: number, width: number): string {
55 if (width <= 0) return ''
56 const f = Math.max(0, Math.min(1, Number.isFinite(frac) ? frac : 0))
57 const eighths = Math.round(f * width * 8)
58 const full = Math.floor(eighths / 8)
59 const part = EIGHTHS[eighths % 8] ?? ''
60 const drawn = '█'.repeat(full) + part
61 // A non-zero value always shows at least a sliver.
62 const shown = drawn === '' && f > 0 ? '▏' : drawn
63 return shown + ' '.repeat(Math.max(0, width - shown.length))
64}
65
66/** Green → lime → amber → red as the share of the largest value grows. */
67export function heat(frac: number): string {
68 if (frac >= 0.75) return '#ef4444'
69 if (frac >= 0.5) return '#f59e0b'
70 if (frac >= 0.25) return '#a3e635'
71 return '#22c55e'
72}
73
74export function truncate(s: string, width: number): string {
75 if (width <= 0) return ''
76 if (s.length <= width) return s
77 if (width === 1) return '…'
78 return `${s.slice(0, width - 1)}…`
79}
80
81function padEnd(s: string, w: number) {
82 return truncate(s, w).padEnd(w)
83}
84
85function padStart(s: string, w: number) {
86 return truncate(s, w).padStart(w)
87}
88
89/** A table row: name, bar, then right-aligned columns, fitted to `inner` cells. */
90function row(inner: number, name: string, frac: number, cols: { text: string; width: number; color?: string; dim?: boolean }[]): Line {
91 const colsW = cols.reduce((n, c) => n + c.width + 1, 0)
92 let nameW = Math.max(6, Math.min(22, Math.floor(inner * 0.34)))
93 let barW = inner - nameW - 1 - colsW
94 if (barW < 3) {
95 nameW = Math.max(4, nameW + barW - 3)
96 barW = Math.max(0, inner - nameW - 1 - colsW)
97 }
98 const line: Line = [{ text: padEnd(name, nameW) + ' ' }]
99 if (barW > 0) line.push({ text: bar(frac, barW), color: heat(frac) })
100 for (const c of cols) line.push({ text: ' ' + padStart(c.text, c.width), color: c.color, dim: c.dim })
101 return line
102}
103
104function header(inner: number, labels: { text: string; width: number }[]): Line {
105 const colsW = labels.reduce((n, c) => n + c.width + 1, 0)
106 const rest = Math.max(0, inner - colsW)
107 return [{ text: ' '.repeat(rest) + labels.map(l => ' ' + padStart(l.text, l.width)).join(''), dim: true }]
108}
109
110function empty(text: string): Line[] {
111 return [[{ text, dim: true }]]
112}
113
114function maxOf(xs: number[]): number {
115 return xs.reduce((m, x) => Math.max(m, x), 0)
116}
117
118/** How many day rows fit, given the pane body's rows (the rest of the dashboard sits around them). */
119export function dailyRowsFor(bodyRows: number, twoColumns: boolean): number {
120 const chrome = twoColumns ? 12 : 13
121 return Math.max(3, Math.min(60, bodyRows - chrome))
122}
123
124/** Panel widths and the list panels for a view, sized to `columns` x `bodyRows`. */
125export function layout(view: View, columns: number, bodyRows: number): Layout {
126 const twoColumns = columns >= TWO_COLUMN_MIN
127 const gap = 1
128 const half = Math.floor((columns - gap) / 2)
129 const w = twoColumns ? half : columns
130 // Border (2) and paddingX (2) eat 4 cells.
131 const inner = Math.max(10, w - 4)
132 const COST = 9
133
134 const dayRows = dailyRowsFor(bodyRows, twoColumns)
135 const days = view.daily.slice(0, dayRows)
136 const maxDay = maxOf(days.map(d => d.cost))
137 const daily: Panel = {
138 id: 'daily',
139 title: 'Daily Activity',
140 color: COLORS.daily,
141 width: w,
142 lines:
143 days.length === 0
144 ? empty('No activity in this range.')
145 : [
146 header(inner, [{ text: 'cost', width: COST }, { text: 'calls', width: 6 }]),
147 ...days.map(d => row(inner, d.day, maxDay > 0 ? d.cost / maxDay : 0, [
148 { text: fmtCost(d.cost), width: COST, color: COLORS.cost },
149 { text: fmtCount(d.calls), width: 6, dim: true },
150 ])),
151 [{ text: `Showing 1–${days.length} of ${view.daily.length} days`, dim: true }],
152 ],
153 }
154
155 const listRows = twoColumns ? Math.max(LIST_ROWS, Math.min(dayRows, 20)) : LIST_ROWS
156 const projects = view.projects.slice(0, listRows)
157 const maxProj = maxOf(projects.map(p => p.cost))
158 const project: Panel = {
159 id: 'project',
160 title: 'By Project',
161 color: COLORS.project,
162 width: w,
163 lines:
164 projects.length === 0
165 ? empty('No projects in this range.')
166 : [
167 header(inner, [{ text: 'cost', width: COST }, { text: 'avg/sess', width: 8 }, { text: 'sess', width: 4 }]),
168 ...projects.map(p => row(inner, p.name, maxProj > 0 ? p.cost / maxProj : 0, [
169 { text: fmtCost(p.cost), width: COST, color: COLORS.cost },
170 { text: fmtCost(p.perSession), width: 8, dim: true },
171 { text: fmtCount(p.sessions), width: 4, dim: true },
172 ])),
173 ],
174 }
175
176 const models = view.models.slice(0, LIST_ROWS)
177 const maxModel = maxOf(models.map(m => m.cost))
178 const model: Panel = {
179 id: 'model',
180 title: 'By Model',
181 color: COLORS.model,
182 width: w,
183 lines:
184 models.length === 0
185 ? empty('No model calls in this range.')
186 : [
187 header(inner, [{ text: 'cost', width: COST }, { text: 'cache', width: 5 }, { text: 'calls', width: 6 }]),
188 ...models.map(m => row(inner, m.name, maxModel > 0 ? m.cost / maxModel : 0, [
189 { text: fmtCost(m.cost), width: COST, color: COLORS.cost },
190 { text: fmtPct(m.cacheHit), width: 5, dim: true },
191 { text: fmtCount(m.calls), width: 6, dim: true },
192 ])),
193 ],
194 }
195
196 const acts = view.activities.slice(0, LIST_ROWS)
197 const maxAct = maxOf(acts.map(a => a.cost))
198 const activity: Panel = {
199 id: 'activity',
200 title: 'By Activity',
201 color: COLORS.activity,
202 width: w,
203 lines:
204 acts.length === 0
205 ? empty('No turns in this range.')
206 : [
207 header(inner, [{ text: 'cost', width: COST }, { text: 'turns', width: 6 }]),
208 ...acts.map(a => row(inner, a.name, maxAct > 0 ? a.cost / maxAct : 0, [
209 { text: fmtCost(a.cost), width: COST, color: COLORS.cost },
210 { text: fmtCount(a.turns), width: 6, dim: true },
211 ])),
212 ],
213 }
214
215 const countPanel = (id: string, title: string, color: string, items: { name: string; count: number }[], none: string): Panel => {
216 const shown = items.slice(0, LIST_ROWS)
217 const max = maxOf(shown.map(i => i.count))
218 return {
219 id,
220 title,
221 color,
222 width: w,
223 lines:
224 shown.length === 0
225 ? empty(none)
226 : [
227 ...shown.map(i => row(inner, i.name, max > 0 ? i.count / max : 0, [{ text: fmtCount(i.count), width: 7 }])),
228 ...(items.length > shown.length ? [[{ text: `+${items.length - shown.length} more`, dim: true }]] : []),
229 ],
230 }
231 }
232 const tools = countPanel('tools', 'Core Tools', COLORS.tools, view.tools, 'No tool calls.')
233 const shell = countPanel('shell', 'Shell Commands', COLORS.shell, view.shell, 'No shell commands.')
234 const mcp = countPanel('mcp', 'MCP Servers', COLORS.mcp, view.mcp, 'No MCP tool calls.')
235
236 const order = [daily, project, model, activity, tools, shell, mcp]
237 const rows: Panel[][] = []
238 if (twoColumns) for (let i = 0; i < order.length; i += 2) rows.push(order.slice(i, i + 2))
239 else for (const p of order) rows.push([p])
240 return { twoColumns, rows }
241}
242
243/** The header panel's lines. */
244export function headerLines(view: View, rangeLabel: string, inner: number): Line[] {
245 const span = view.from === '' ? `through ${view.to}` : view.from === view.to ? view.to : `${view.from} → ${view.to}`
246 return [
247 [
248 { text: 'burn-rate ', bold: true, color: COLORS.header },
249 { text: rangeLabel, bold: true },
250 { text: truncate(` ${span} · est. at list price`, Math.max(0, inner - 11 - rangeLabel.length)), dim: true },
251 ],
252 [
253 { text: fmtCost(view.cost), bold: true, color: COLORS.cost },
254 {
255 text: truncate(
256 ` ${fmtCount(view.calls)} calls · ${fmtCount(view.sessions)} sessions · ${fmtPct(view.cacheHit)} cache hit`,
257 Math.max(0, inner - fmtCost(view.cost).length),
258 ),
259 },
260 ],
261 [
262 {
263 text: truncate(
264 `tokens in ${fmtTokens(view.input)} · out ${fmtTokens(view.output)} · cached ${fmtTokens(view.cacheRead)} · written ${fmtTokens(view.cacheWrite)}`,
265 inner,
266 ),
267 dim: true,
268 },
269 ],
270 ]
271}
272hooks/parse.ts 357 lines1// Turns transcript JSONL lines into a small per-file aggregate that can be stored and extended.
2
3import { EDIT_TOOLS, classify, freshSignals, isDocFile, isTestRun } from './classify'
4import type { Category, TurnSignals } from './classify'
5import { costOf } from './pricing'
6import type { Usage } from './pricing'
7
8/** Per model: [calls, input, output, cacheRead, cacheWrite5m, cacheWrite1h, costUsd]. */
9export type ModelRow = [number, number, number, number, number, number, number]
10
11/** One local day of one transcript file. */
12export type DayAgg = {
13 /** Project (last folder of `cwd`) last seen that day. */
14 p?: string
15 m: Record<string, ModelRow>
16 /** Activity: [costUsd, turns] per category, for turns closed so far. */
17 a: Partial<Record<Category, [number, number]>>
18 /** Built-in tool calls by name. */
19 t: Record<string, number>
20 /** Shell commands by first word. */
21 s: Record<string, number>
22 /** MCP tool calls by server. */
23 x: Record<string, number>
24}
25
26/** The turn still open at the end of what has been parsed. */
27export type OpenTurn = { day: string; s: TurnSignals; c: number; n: number }
28
29/** A response already counted: [id, model, day, input, output, cacheRead, cw5m, cw1h]. */
30export type Seen = [string, string, string, number, number, number, number, number]
31
32export type FileState = {
33 v: 2
34 /** Size and mtime when last parsed. */
35 size: number
36 mtime: number
37 /** Bytes consumed (complete lines only). */
38 off: number
39 sid?: string
40 proj?: string
41 days: Record<string, DayAgg>
42 /** The last few responses counted, so a streamed duplicate across a read boundary is still caught. */
43 seen: Seen[]
44 /** The last few tool_use ids counted. */
45 tu: string[]
46 turn: OpenTurn | null
47}
48
49const RECENT = 12
50
51export function emptyFile(): FileState {
52 return { v: 2, size: 0, mtime: 0, off: 0, days: {}, seen: [], tu: [], turn: null }
53}
54
55/** YYYY-MM-DD of `ms` shifted by `tzOffsetMin` (minutes east of UTC). */
56export function dayKey(ms: number, tzOffsetMin: number): string {
57 return new Date(ms + tzOffsetMin * 60_000).toISOString().slice(0, 10)
58}
59
60/** `/home/me/src/app` → `app`; `C:\\work\\api\\` → `api`. */
61export function shortProject(cwd: string): string {
62 const parts = cwd.split(/[\\/]+/).filter(Boolean)
63 return parts[parts.length - 1] ?? cwd
64}
65
66const PREFIX_WORDS = new Set(['sudo', 'env', 'time', 'nohup', 'exec', 'command', 'builtin', 'nice'])
67
68/**
69 * The program a shell command runs: its first word, past `sudo`, `env X=y`,
70 * `X=y` assignments, and leading `cd dir &&` / `cd dir;` / `X=y;` steps. Null when none.
71 */
72export function shellCommand(command: string): string | null {
73 let s = command.trim()
74 for (let guard = 0; guard < 8; guard++) {
75 s = s.replace(/^[({]\s*/, '')
76 // `cd dir &&`, `cd dir;`, and a bare `X=y;` / `export X=y &&` step before the real command.
77 const step =
78 /^(cd|pushd)\s+("[^"]*"|'[^']*'|[^\s;&|]+)(\s+\d?>>?\s*\S+)*\s*(&&|\|\||;|\n)\s*/.exec(s) ??
79 /^(export\s+)?[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|[^\s;&|]*)\s*(&&|;|\n)\s*/.exec(s)
80 if (step === null) break
81 s = s.slice(step[0].length)
82 }
83 const words = s.split(/\s+/).filter(Boolean)
84 let i = 0
85 while (i < words.length) {
86 const w = words[i] ?? ''
87 if (PREFIX_WORDS.has(w)) i++
88 else if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) i++
89 else if (w.startsWith('-') && i > 0 && PREFIX_WORDS.has(words[i - 1] ?? '')) i++
90 else break
91 }
92 const first = (words[i] ?? '').replace(/^["']|["';|&)]+$/g, '')
93 if (first === '' || /^[;&|]/.test(first)) return null
94 const base = first.split(/[\\/]/).pop() ?? first
95 return base === '' ? null : base
96}
97
98/** `mcp__github__create_issue` → `github`; anything else → null. */
99export function mcpServer(tool: string): string | null {
100 if (!tool.startsWith('mcp__')) return null
101 const server = tool.slice(5).split('__')[0]
102 return server ? server : null
103}
104
105function day(state: FileState, key: string): DayAgg {
106 let d = state.days[key]
107 if (d === undefined) {
108 d = { m: {}, a: {}, t: {}, s: {}, x: {} }
109 state.days[key] = d
110 }
111 return d
112}
113
114function bump(map: Record<string, number>, key: string, by = 1) {
115 map[key] = (map[key] ?? 0) + by
116}
117
118function num(v: unknown): number {
119 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
120}
121
122/** The usage of an assistant line, cache writes split 5m/1h (all 5m when the split is absent). */
123export function usageOf(raw: unknown): Usage {
124 const u = (raw ?? {}) as Record<string, unknown>
125 const written = num(u.cache_creation_input_tokens)
126 const split = (u.cache_creation ?? null) as Record<string, unknown> | null
127 let w5 = written
128 let w1 = 0
129 if (split !== null && typeof split === 'object') {
130 const a = num(split.ephemeral_5m_input_tokens)
131 const b = num(split.ephemeral_1h_input_tokens)
132 if (a + b > 0) {
133 w1 = b
134 w5 = Math.max(0, written - b)
135 }
136 }
137 return {
138 input: num(u.input_tokens),
139 output: num(u.output_tokens),
140 cacheRead: num(u.cache_read_input_tokens),
141 cacheWrite5m: w5,
142 cacheWrite1h: w1,
143 }
144}
145
146function closeTurn(state: FileState) {
147 const t = state.turn
148 state.turn = null
149 if (t === null || t.n === 0) return
150 const cat = classify(t.s)
151 const d = day(state, t.day)
152 const row = d.a[cat] ?? [0, 0]
153 d.a[cat] = [row[0] + t.c, row[1] + 1]
154}
155
156/** The text a person typed, or null for a row that is no prompt (tool results, meta, command records). */
157function promptText(obj: Record<string, unknown>): string | null {
158 if (obj.isMeta === true) return null
159 const msg = obj.message as { content?: unknown } | undefined
160 const c = msg?.content
161 let text = ''
162 if (typeof c === 'string') text = c
163 else if (Array.isArray(c)) {
164 const texts = c.filter(b => b && typeof b === 'object' && (b as { type?: unknown }).type === 'text')
165 if (texts.length === 0) return null
166 text = texts.map(b => String((b as { text?: unknown }).text ?? '')).join('\n')
167 } else return null
168 text = text.trim()
169 // Slash-command records, local command output and harness notices are not prompts.
170 if (text === '' || text.startsWith('<')) return null
171 return text
172}
173
174function addUsage(state: FileState, dayKeyStr: string, model: string, u: Usage, calls: number, cost: number) {
175 const d = day(state, dayKeyStr)
176 const row = d.m[model] ?? [0, 0, 0, 0, 0, 0, 0]
177 row[0] += calls
178 row[1] += u.input
179 row[2] += u.output
180 row[3] += u.cacheRead
181 row[4] += u.cacheWrite5m
182 row[5] += u.cacheWrite1h
183 row[6] += cost
184 d.m[model] = row
185}
186
187function ensureTurn(state: FileState, dk: string): OpenTurn {
188 if (state.turn === null) state.turn = { day: dk, s: freshSignals(''), c: 0, n: 0 }
189 return state.turn
190}
191
192function onAssistant(state: FileState, obj: Record<string, unknown>, tz: number) {
193 if (obj.isApiErrorMessage === true) return
194 const msg = (obj.message ?? {}) as Record<string, unknown>
195 const model = typeof msg.model === 'string' ? msg.model : ''
196 if (model === '' || model === '<synthetic>') return
197 const ts = Date.parse(String(obj.timestamp ?? ''))
198 const dk = Number.isFinite(ts) ? dayKey(ts, tz) : (state.turn?.day ?? '1970-01-01')
199 const u = usageOf(msg.usage)
200 const total = u.input + u.output + u.cacheRead + u.cacheWrite5m + u.cacheWrite1h
201 if (total === 0) return
202
203 const id = String(msg.id ?? obj.requestId ?? obj.uuid ?? '')
204 const turn = ensureTurn(state, dk)
205 const prior = id === '' ? undefined : state.seen.find(s => s[0] === id)
206 if (prior === undefined) {
207 const cost = costOf(model, u)
208 addUsage(state, dk, model, u, 1, cost)
209 turn.c += cost
210 turn.n += 1
211 if (id !== '') {
212 state.seen.push([id, model, dk, u.input, u.output, u.cacheRead, u.cacheWrite5m, u.cacheWrite1h])
213 if (state.seen.length > RECENT) state.seen.splice(0, state.seen.length - RECENT)
214 }
215 } else {
216 // Same response streamed again: count only what grew (the last line carries the final output count).
217 const old: Usage = { input: prior[3], output: prior[4], cacheRead: prior[5], cacheWrite5m: prior[6], cacheWrite1h: prior[7] }
218 const merged: Usage = {
219 input: Math.max(old.input, u.input),
220 output: Math.max(old.output, u.output),
221 cacheRead: Math.max(old.cacheRead, u.cacheRead),
222 cacheWrite5m: Math.max(old.cacheWrite5m, u.cacheWrite5m),
223 cacheWrite1h: Math.max(old.cacheWrite1h, u.cacheWrite1h),
224 }
225 const grew = (Object.keys(merged) as (keyof Usage)[]).some(k => merged[k] > old[k])
226 if (grew) {
227 const delta: Usage = {
228 input: merged.input - old.input,
229 output: merged.output - old.output,
230 cacheRead: merged.cacheRead - old.cacheRead,
231 cacheWrite5m: merged.cacheWrite5m - old.cacheWrite5m,
232 cacheWrite1h: merged.cacheWrite1h - old.cacheWrite1h,
233 }
234 const cost = costOf(prior[1], merged) - costOf(prior[1], old)
235 addUsage(state, prior[2], prior[1], delta, 0, cost)
236 turn.c += cost
237 prior[3] = merged.input
238 prior[4] = merged.output
239 prior[5] = merged.cacheRead
240 prior[6] = merged.cacheWrite5m
241 prior[7] = merged.cacheWrite1h
242 }
243 }
244
245 const content = Array.isArray(msg.content) ? msg.content : []
246 for (const block of content) {
247 const b = block as { type?: unknown; id?: unknown; name?: unknown; input?: unknown }
248 if (!b || b.type !== 'tool_use' || typeof b.name !== 'string') continue
249 const bid = typeof b.id === 'string' ? b.id : ''
250 if (bid !== '') {
251 if (state.tu.includes(bid)) continue
252 state.tu.push(bid)
253 if (state.tu.length > RECENT) state.tu.splice(0, state.tu.length - RECENT)
254 }
255 const d = day(state, dk)
256 const input = (b.input ?? {}) as Record<string, unknown>
257 turn.s.t += 1
258 const server = mcpServer(b.name)
259 if (server !== null) {
260 bump(d.x, server)
261 continue
262 }
263 bump(d.t, b.name)
264 if (b.name === 'Bash' && typeof input.command === 'string') {
265 const cmd = shellCommand(input.command)
266 if (cmd !== null) bump(d.s, cmd)
267 if (isTestRun(input.command)) turn.s.r = true
268 }
269 if (EDIT_TOOLS.has(b.name)) {
270 turn.s.e += 1
271 const path = input.file_path ?? input.notebook_path
272 if (typeof path === 'string' && isDocFile(path)) turn.s.d += 1
273 }
274 }
275}
276
277/** Applies one parsed JSONL object to the state. */
278export function applyLine(state: FileState, obj: Record<string, unknown>, tz: number) {
279 if (typeof obj.sessionId === 'string' && state.sid === undefined) state.sid = obj.sessionId
280 if (typeof obj.cwd === 'string' && obj.cwd !== '') state.proj = shortProject(obj.cwd)
281 if (obj.type === 'user') {
282 const text = promptText(obj)
283 if (text === null) return
284 closeTurn(state)
285 const ts = Date.parse(String(obj.timestamp ?? ''))
286 const dk = Number.isFinite(ts) ? dayKey(ts, tz) : '1970-01-01'
287 state.turn = { day: dk, s: freshSignals(text), c: 0, n: 0 }
288 if (state.proj !== undefined) day(state, dk).p = state.proj
289 } else if (obj.type === 'assistant') {
290 onAssistant(state, obj, tz)
291 if (state.proj !== undefined && state.turn !== null) {
292 const d = state.days[state.turn.day]
293 if (d !== undefined && d.p === undefined) d.p = state.proj
294 }
295 }
296}
297
298/** UTF-8 byte length of a string. */
299export function byteLength(s: string): number {
300 let n = 0
301 for (let i = 0; i < s.length; i++) {
302 const c = s.charCodeAt(i)
303 if (c < 0x80) n += 1
304 else if (c < 0x800) n += 2
305 else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
306 n += 4
307 i++
308 } else n += 3
309 }
310 return n
311}
312
313/** The index in `s` where its first `bytes` UTF-8 bytes end. */
314export function indexAtByte(s: string, bytes: number): number {
315 let n = 0
316 let i = 0
317 while (i < s.length && n < bytes) {
318 const c = s.charCodeAt(i)
319 if (c < 0x80) n += 1
320 else if (c < 0x800) n += 2
321 else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
322 n += 4
323 i++
324 } else n += 3
325 i++
326 }
327 return i
328}
329
330/**
331 * Parses the complete lines of `text` (everything up to its last newline) into `state`
332 * and returns how many bytes that was; a trailing partial line is left for next time.
333 */
334export function parseText(state: FileState, text: string, tz: number): number {
335 const end = text.lastIndexOf('\n')
336 if (end < 0) return 0
337 const body = text.slice(0, end + 1)
338 for (const line of body.split('\n')) {
339 if (line.length < 2 || line.charCodeAt(0) !== 123 /* { */) continue
340 // Cheap skip of rows that never matter before paying for JSON.parse.
341 if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"') && !line.includes('"type": "')) continue
342 let obj: unknown
343 try {
344 obj = JSON.parse(line)
345 } catch {
346 continue
347 }
348 if (obj && typeof obj === 'object') applyLine(state, obj as Record<string, unknown>, tz)
349 }
350 return byteLength(body)
351}
352
353/** The category the open turn would get if it ended now (for display). */
354export function openTurnCategory(t: OpenTurn): Category {
355 return classify(t.s)
356}
357hooks/pricing.ts 77 lines1// List prices in USD per million tokens. Cache writes are priced off `input`:
2// 1.25x for the 5-minute TTL, 2x for the 1-hour TTL. Estimates only.
3
4export type Price = {
5 input: number
6 output: number
7 cacheRead: number
8 /** A higher tier that applies when the prompt (input + cache read + cache write) exceeds `above` tokens. */
9 long?: { above: number; input: number; output: number; cacheRead: number }
10}
11
12/** Matched by prefix, longest first, so `claude-opus-5-5` wins over `claude-opus-5`. */
13export const PRICES: Readonly<Record<string, Price>> = {
14 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25 },
15 'claude-fable-5': { input: 10, output: 50, cacheRead: 0.25 },
16 'claude-mythos-': { input: 10, output: 50, cacheRead: 0.25 },
17 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2 },
18 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5 },
19 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5 },
20 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5 },
21 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5 },
22 'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.1 },
23 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2 },
24 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3 },
25 'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.3 },
26 'claude-haiku-5-5': {
27 input: 0.1,
28 output: 0.5,
29 cacheRead: 0.01,
30 long: { above: 100_000, input: 0.5, output: 2.5, cacheRead: 0.05 },
31 },
32 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1 },
33}
34
35const PREFIXES = Object.keys(PRICES).sort((a, b) => b.length - a.length)
36
37/** Token counts of one API response, cache writes split by TTL. */
38export type Usage = {
39 input: number
40 output: number
41 cacheRead: number
42 cacheWrite5m: number
43 cacheWrite1h: number
44}
45
46/** The price row for a model id (`claude-opus-5-5`, `claude-opus-5-5[1m]`, `us.anthropic.claude-...`), or undefined. */
47export function priceOf(model: string): Price | undefined {
48 const id = model.toLowerCase().replace(/^.*?(claude-)/, '$1')
49 const key = PREFIXES.find(p => id.startsWith(p))
50 return key === undefined ? undefined : PRICES[key]
51}
52
53/** USD for one response at list price; 0 for a model with no price. */
54export function costOf(model: string, u: Usage): number {
55 const p = priceOf(model)
56 if (p === undefined) return 0
57 const prompt = u.input + u.cacheRead + u.cacheWrite5m + u.cacheWrite1h
58 const tier = p.long !== undefined && prompt > p.long.above ? p.long : p
59 const usd =
60 u.input * tier.input +
61 u.output * tier.output +
62 u.cacheRead * tier.cacheRead +
63 u.cacheWrite5m * tier.input * 1.25 +
64 u.cacheWrite1h * tier.input * 2
65 return usd / 1_000_000
66}
67
68/** `claude-opus-5-5` → `Opus 5.5`; an unpriced id → `(unpriced) <id>`. */
69export function modelLabel(model: string): string {
70 if (priceOf(model) === undefined) return `(unpriced) ${model}`
71 const id = model.toLowerCase().replace(/^.*?claude-/, '').replace(/\[.*\]$/, '')
72 const parts = id.split('-').filter(p => !/^\d{8}$/.test(p))
73 const [family = '', ...ver] = parts
74 const name = family.charAt(0).toUpperCase() + family.slice(1)
75 return ver.length > 0 ? `${name} ${ver.join('.')}` : name
76}
77hooks/classify.ts 89 lines1// Heuristics: what kind of work a turn was, from the prompt's words and the tools the turn used.
2
3export const CATEGORIES = [
4 'Coding',
5 'Debugging',
6 'Feature Dev',
7 'Exploration',
8 'Refactoring',
9 'Testing',
10 'Docs',
11 'Conversation',
12] as const
13export type Category = (typeof CATEGORIES)[number]
14
15/** What a turn did, gathered while its lines are parsed (kept small: it is stored). */
16export type TurnSignals = {
17 /** Category the prompt's words point at, or null. */
18 k: KeywordCategory | null
19 /** Tool calls made. */
20 t: number
21 /** Edits made (Edit, Write, MultiEdit, NotebookEdit). */
22 e: number
23 /** Edits to documentation files (.md, .mdx, .rst, .txt, README). */
24 d: number
25 /** A test runner ran in the shell. */
26 r: boolean
27}
28
29type KeywordCategory = 'Testing' | 'Debugging' | 'Refactoring' | 'Docs' | 'Feature Dev' | 'Exploration'
30
31// First match wins, in this order.
32const KEYWORDS: readonly [KeywordCategory, RegExp][] = [
33 ['Testing', /\b(tests?|testing|unit[- ]tests?|specs?|jest|vitest|pytest|mocha|coverage|e2e)\b/],
34 ['Debugging', /\b(bugs?|fix(es|ed|ing)?|errors?|fail(s|ed|ing|ure)?|broken|crash(es|ed|ing)?|exception|traceback|stack ?trace|debug(ging)?|regression|doesn'?t work|not working|wrong)\b/],
35 ['Refactoring', /\b(refactor(ing)?|rename|clean ?up|simplify|restructure|reorgani[sz]e|extract|dedupe|deduplicate|tidy|modernize)\b/],
36 ['Docs', /\b(readme|docs?|documentation|docstrings?|jsdoc|changelog|write ?up|comments?)\b/],
37 ['Feature Dev', /\b(add|implement|build|create|feature|support for|introduce|scaffold|new endpoint|new command)\b/],
38 ['Exploration', /\b(how does|how do|where is|where are|what is|what does|explain|find|look at|investigate|explore|understand|search|show me|list)\b/],
39]
40
41export const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
42
43const TEST_RUNNER =
44 /(^|[\s;&|(])(npm (run )?test|pnpm (run )?test|yarn test|bun test|npx (jest|vitest|mocha|playwright)|jest|vitest|mocha|pytest|python -m (pytest|unittest)|go test|cargo test|mvn test|gradle test|rspec|phpunit|dotnet test|claude plugin test|make test|tox)\b/
45
46const DOC_FILE = /(\.(md|mdx|rst|txt|adoc)$|(^|[\\/])readme[^\\/]*$)/i
47
48export function keywordOf(prompt: string): KeywordCategory | null {
49 const text = prompt.toLowerCase()
50 for (const [cat, re] of KEYWORDS) if (re.test(text)) return cat
51 return null
52}
53
54export function isTestRun(command: string): boolean {
55 return TEST_RUNNER.test(command)
56}
57
58export function isDocFile(path: string): boolean {
59 return DOC_FILE.test(path)
60}
61
62export function freshSignals(prompt: string): TurnSignals {
63 return { k: keywordOf(prompt), t: 0, e: 0, d: 0, r: false }
64}
65
66/**
67 * Rules, first that holds wins:
68 * 1. a test runner ran, or the prompt talks about tests -> Testing
69 * 2. the prompt talks about a bug/error/failure -> Debugging
70 * 3. the prompt asks to refactor/rename/clean up -> Refactoring
71 * 4. every edit was to a doc file, or the prompt is about docs -> Docs
72 * 5. the prompt asks to add/implement/build and files were edited -> Feature Dev
73 * 6. files were edited -> Coding
74 * 7. tools ran but nothing was edited -> Exploration
75 * 8. the prompt asks how/where/what/explain (no tools) -> Exploration
76 * 9. otherwise (no tools) -> Conversation
77 */
78export function classify(s: TurnSignals): Category {
79 if (s.r || s.k === 'Testing') return 'Testing'
80 if (s.k === 'Debugging') return 'Debugging'
81 if (s.k === 'Refactoring') return 'Refactoring'
82 if ((s.e > 0 && s.d === s.e) || s.k === 'Docs') return 'Docs'
83 if (s.k === 'Feature Dev' && s.e > 0) return 'Feature Dev'
84 if (s.e > 0) return 'Coding'
85 if (s.t > 0) return 'Exploration'
86 if (s.k === 'Exploration') return 'Exploration'
87 return 'Conversation'
88}
89types/index.d.ts 65 lines1export type Range = 'today' | '7d' | '30d' | 'month' | '6m' | 'lifetime'
2
3export type ActivityName =
4 | 'Coding'
5 | 'Debugging'
6 | 'Feature Dev'
7 | 'Exploration'
8 | 'Refactoring'
9 | 'Testing'
10 | 'Docs'
11 | 'Conversation'
12
13/** Everything the dashboard shows for one date range. Costs are USD at list price. */
14export type View = {
15 range: Range
16 /** First day included (YYYY-MM-DD, local), '' for lifetime; `to` is today. */
17 from: string
18 to: string
19 cost: number
20 calls: number
21 sessions: number
22 input: number
23 output: number
24 cacheRead: number
25 cacheWrite: number
26 /** cacheRead / (input + cacheRead + cacheWrite), 0..1. */
27 cacheHit: number
28 /** Newest first, days with calls only. */
29 daily: { day: string; cost: number; calls: number }[]
30 projects: { name: string; cost: number; sessions: number; perSession: number }[]
31 models: { name: string; cost: number; calls: number; cacheHit: number }[]
32 activities: { name: ActivityName; cost: number; turns: number }[]
33 tools: { name: string; count: number }[]
34 shell: { name: string; count: number }[]
35 mcp: { name: string; count: number }[]
36}
37
38export type ScanStatus = {
39 isScanning: boolean
40 /** Files looked at so far / found, during a scan. */
41 done: number
42 total: number
43 /** Files re-read (changed or new) by the last scan. */
44 parsed: number
45 /** Files skipped because they could not be read. */
46 skipped: number
47 /** When the last scan finished (ms since epoch), 0 before the first. */
48 finishedAt: number
49 error?: string
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'burn-rate': {
55 /** The range picked in the pane; null until the person picks one (the `defaultRange` setting applies). */
56 range: Range | null
57 /** Every range's view from the last scan; null before the first. */
58 views: Record<Range, View> | null
59 status: ScanStatus
60 /** Cost of turns finished since the last scan (for the status line). */
61 sinceScan: number
62 }
63 }
64}
65