SLOPSHOPPER

burn-rate

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

newpanecommandstatusprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-08TroyJLorents-GH/mod-squad/mods/burn-rate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · burn-rate
│ ┃ burn-rate ✕ › fix the failing auth test and add an audit log call │ ┃ [ Today ] [ 7 Days ] [ 30 Days ] [ This Mont │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ burn-rate ⎿ Read 6 lines │ ┃ │ No data yet: press r to scan. ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ 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 │ │ › /burn │ ⎿ burn-rate: burn-rate: scanning your transcripts… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ burn-rate: 🔥 $0.08 this session

Draws

Pane · burn-rate
[ Today ] [ 7 Days ] [ 30 Days ] [ This Month ] [ 6 Months ] ╭──────────────────────────────────────────────────────╮ │ burn-rate │ │ No data yet: press r to scan. │ ╰──────────────────────────────────────────────────────╯
README

burn-rate

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.

Install

/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install burn-rate@mod-squad

Use

  • /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.
  • Hotkeys in the pane: 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.
  • Setting: defaultRange (one of today, 7d, 30d, month, 6m, lifetime; default 7d) is the range the pane opens on until you pick another.

The panels:

PanelWhat it shows
HeaderTotal cost, API calls, sessions, cache-hit % (cache reads ÷ all prompt tokens), and tokens in, out, cached (read) and written (cache writes).
Daily ActivityOne 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 ProjectThe project is the last folder of the transcript's cwd. Shows cost, average cost per session and the number of sessions.
By ModelCost, cache-hit % and calls for each model. A model with no price is listed as (unpriced) <id> and costs $0.
By ActivityCost and turns for each kind of work (rules below).
Core ToolsCalls to each built-in tool.
Shell CommandsThe 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 Serversmcp__<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.

The numbers are estimates

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:

ModelInputOutputCache read
claude-fable-5-1, claude-fable-5, claude-mythos-*10500.25
claude-opus-5-54200.20
claude-opus-5, -4-8, -4-7, -4-65250.50
claude-sonnet-5-52100.10
claude-sonnet-52100.20
claude-sonnet-4-6, -4-53150.30
claude-haiku-5-5 (prompt ≤ 100k)0.100.500.01
claude-haiku-5-5 (prompt > 100k)0.502.500.05
claude-haiku-4-5150.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.

Where the data comes from

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.

  • Calls: each 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.
  • Days: a line's day is the local date of its timestamp. The UTC offset comes from the host's date +%z.
  • Sessions: distinct sessionIds with calls in the range.
  • Turns: a turn starts at each prompt you typed. Tool results, meta rows and command records don't start a turn. A turn's cost is the sum of the calls up to the next prompt.

Activity rules

Each turn is classified from its prompt's words and the tools it used. The first rule that matches wins:

  1. A test runner ran in Bash (npm test, pytest, go test, cargo test, vitest, …), or the prompt mentions tests: Testing.
  2. The prompt mentions a bug, fix, error, failure, crash or debugging: Debugging.
  3. The prompt asks to refactor, rename, clean up, simplify or extract: Refactoring.
  4. Every edit was to a doc file (.md, .mdx, .rst, .txt, README), or the prompt is about docs or a README: Docs.
  5. The prompt asks to add, implement, build or create something, and files were edited: Feature Dev.
  6. Files were edited (Edit, Write, MultiEdit, NotebookEdit): Coding.
  7. Tools ran but nothing was edited: Exploration.
  8. No tools ran and the prompt asks how, where or what, or asks for an explanation: Exploration.
  9. Anything else: Conversation.

Performance

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.

  • Each file's aggregate (per day: per-model tokens and cost, activity, tool, shell and MCP counts) is kept in the mod's $.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.
  • Files up to 4 MB are read with $.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.
  • Aggregates of transcripts that Claude Code has since cleaned up (cleanupPeriodDays) stay in the store, so Lifetime keeps them.
  • The status line adds the cost of each finished turn (from turn.complete's usage) to the last scan's total for today, so it costs nothing to compute.

Limits:

  • Reading files over 4 MB needs 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.
  • The local-date offset is read once per scan, so days across a DST change may be off by an hour at midnight.
  • Each response is counted once within its own file. If a resumed or forked session copies earlier responses into a new file, those responses are counted in both files.

Develop

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.

Source 7 files
hooks/register.tsx 365 lines
1import { 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}
365
hooks/aggregate.ts 192 lines
1// 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}
192
hooks/layout.ts 272 lines
1// 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}
272
hooks/parse.ts 357 lines
1// 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}
357
hooks/pricing.ts 77 lines
1// 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}
77
hooks/classify.ts 89 lines
1// 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}
89
types/index.d.ts 65 lines
1export 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