SLOPSHOPPER

usage-meter

Live view of where every token goes: plan limit %, tokens and API-equivalent $, by machine, thread, subagent, task and model

newpanebandguardcommandtoast
★ 10v0.2.0MITupdated 2026-10-09rohit3a/usage-meter/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ Starting up │ ┃ Waiting for the collector's first summary. I ⏺ Read(src/auth.ts) │ ┃ stays, the collector isn't running: see the ⎿ Read 6 lines │ ┃ github.com/rohit3a/usage-meter. ⏺ 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 │ │ last turn · $0.00 · 0 requests · 0 tok details hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-meter: 5h 31% · $0.42 session

Draws

Band
last turn · $0.00 · 0 requests · 0 tok details hide
Pane · Usage
Starting up Waiting for the collector's first summary. If this stays, the collector isn't running: see the README at github.com/rohit3a/usage-meter.
README

usage-meter

Where every Claude token goes, live. A Claude Code mod that shows your plan-limit %, tokens and API-equivalent $, split by machine, session, subagent, prompt and model, right inside Claude Code: in the status line, a live band above the prompt, and a /burn pane.

  • Limit %, learned from your own data. It fits how fast each model burns your plan's 5h and weekly limits, so "budget left" is in real terms rather than guesswork.
  • API-equivalent $ for every request at list prices, cache writes by TTL, fast mode, web search.
  • Every machine in one view. Run a collector on each box and they sync over SSH.
  • Codex too. Follows Codex runs, links the ones Claude launched to the chat that started them, and can stop them.
  • Never double counts. Every request is keyed by its API message id.
  • Stays fast. A background collector does the math; the mod only redraws when the summary changes.

Install

Needs Python 3.9+ and Claude Code (terminal or the desktop app's Code tab). Linux or macOS.

git clone https://github.com/rohit3a/usage-meter.git
cd usage-meter
deploy/install.sh

Then open a new Claude Code session (or run /reload-plugins in an open one) and type /burn.

install.sh is idempotent. It links mod/ into ~/.claude/skills/usage-meter so every session loads it, and installs the collector as a systemd user service (Linux) or launchd agent (macOS). The first run reads your existing transcripts, so history shows up right away.

More than one machine

Name each machine and point it at the others by SSH host (anything ssh <host> reaches, e.g. a Tailscale name). Key-based SSH is required; sync runs in the background.

deploy/install.sh --host Desktop --peer Laptop=my-laptop   # on the desktop
deploy/install.sh --host Laptop                             # on the laptop

How it works

  • collector/umeter.py: one per machine, always running (systemd user service on Linux, launchd agent on macOS). Reads Claude Code's transcripts (~/.claude/projects/**.jsonl) incrementally into a compact ledger, one file per UTC day, at ~/.claude/usage-meter/hosts/<host>/days/. Each request is keyed by its API message id, so nothing is counted twice, and carries its model, effort level, skill and MCP server. With peers configured it syncs every 20 s over SSH: it pushes its own hosts/<host>/ and pulls each peer's.
  • collector/view.py: inside the collector, does all the arithmetic in its own process and writes local/view.json (about 75 KB), plus a detail file per recently active session in local/sessions/. A rebuild takes about 0.15 s and only runs when the ledger or the limit readings change.
  • mod/: the Claude Code mod. It reads view.json only when it changes (a stat every 2 s while the pane is open), records the plan-limit readings only a live session sees, and draws the status line, the live band above the prompt and the /burn pane. It never crunches the ledger itself, so the UI stays responsive.
  • collector/live.py: follows Codex runs (~/.codex/sessions/**/rollout-*.jsonl) and live Claude sessions (Claude Code's own ~/.claude/sessions/<pid>.json plus each transcript's tail). Each machine writes hosts/<host>/codex.json and fleet.json (synced); the collector merges every machine's into local/live.json. A Codex run Claude started (codex exec in a Bash call, or T3 Code's delegate_task) is linked to that chat and prompt. Stop drops local/stop/<run>.json; the collector sends SIGINT (SIGTERM after 8 s) only to a process that is Codex and holds that run's log open.
  • mod/prices.json: list prices for the collector. Cache writes are priced by TTL (5m 1.25×, 1h 2× input); fast mode 2× on Opus 5/5.5; web search $0.01.

Data

~/.claude/usage-meter/
  config.json                    plan name and price (synced)
  local/view.json                everything the pane draws (rebuilt by the collector)
  local/sessions/<id>.json       per-session detail for "This session"
  local/live.json                Codex runs, Codex limits and live sessions, every machine
  local/refresh                  the pane's Refresh button: rebuild now
  hosts/<host>/days/<day>.json   requests; written only by <host>'s collector
  hosts/<host>/limits.json       5h / 7d limit readings
  hosts/<host>/status.json       collector heartbeat and sync results
  local/collector.json           this machine's name and sync peers
  local/state.json               transcript read offsets (rebuildable)

The desktop app keeps local copies of sessions it runs on another machine over SSH (~/.claude/projects/ssh-* on the client). They're ingested with the session marked remote. A message from a remote copy counts only when no machine recorded it as its own, so remote sessions aren't double counted and a remote host without a collector still shows up.

How the % is attributed

Two meters, kept separate:

  • API-equivalent $: every request at today's API list prices (mod/prices.json, with dated changes), so the Sonnet 5.5 cache-read cut shows up here.
  • Limit %: how fast each model uses your plan's limits, learned from data. Anthropic reports each window in whole points. Sessions save a reading on every change (and every 10 minutes), so the collector knows when each point was first seen while someone was watching. Between two such crossings, points = Σ over model families (weight × plan-reference $), where plan-reference $ ignores API-only price changes ("apiOnly": true; Claude Code plan limits didn't change with the Sonnet cut). The weights are fit by least squares, each pulled toward the overall rate with about one point's worth of evidence, so a family with no data counts like its price and one with data counts like its data.

In practice Sonnet 5.5 cache reads have measured at about 0.70× Opus per token, and a week holds about 3.5 full 5h windows. Your own weights are learned from your own readings.

Budget left = (100 − % − 0.5) / Opus weight, shown as Opus-equivalent $. Burn = the last fifth of the window's usage by model × its weight. "Outside Claude Code" is only claimed when there are 3+ consistent stretches and the gap is a whole point.

Check

systemctl --user status usage-meter          # Linux (macOS: launchctl print gui/$UID/dev.usage-meter.collector)
python3 collector/umeter.py report --hours 5 # spend per machine and session
(cd mod && claude plugin validate . && claude plugin test .)

Bump VERSION in umeter.py when the ledger format changes: each machine rebuilds from its transcripts on restart (about 20 s to a minute).

License

MIT

Source 2 files
hooks/register.tsx 1314 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type {
5  CodexRun,
6  CodexWindow,
7  ContextView,
8  FleetEntry,
9  HistoryDay,
10  Live,
11  Pulse,
12  Scope,
13  ScopeView,
14  Tab,
15  ThreadRow,
16  Tokens,
17  View,
18  ViewRow,
19  WindowView,
20} from '../types'
21
22// The collector (collector/umeter.py, one per machine) reads Claude Code's
23// transcripts, syncs every machine's ledger and does all the arithmetic in
24// its own process, writing ~/.claude/usage-meter/local/view.json. This mod
25// only reads that file when it changes, records the plan-limit readings a
26// live session sees, and draws. Nothing heavy runs inside Claude Code.
27
28type $T = EngineInterface
29/** ts, window kind, percent used, resetsAt (ms, 0 unknown) */
30type Lim = [number, string, number, number]
31
32const PANE = 'usage-meter'
33const HEARTBEAT = 10 * 60_000
34const ALERTS = [80, 90, 95]
35const LABELS: Record<string, string | undefined> = { five_hour: '5h', seven_day: '7d' }
36const HOST_COLORS = ['#58a6ff', '#bc8cff', '#3fb950', '#d29922', '#8b949e']
37
38const viewA = atom({ plugin: 'usage-meter', key: 'view' } as const, null)
39const pulseA = atom({ plugin: 'usage-meter', key: 'pulse' } as const, null)
40const mineA = atom({ plugin: 'usage-meter', key: 'mine' } as const, null)
41const contextA = atom({ plugin: 'usage-meter', key: 'context' } as const, null)
42const ccA = atom({ plugin: 'usage-meter', key: 'cc' } as const, null)
43const liveA = atom({ plugin: 'usage-meter', key: 'live' } as const, null)
44const tabA = atom({ plugin: 'usage-meter', key: 'tab' } as const, 'overview')
45const scopeA = atom({ plugin: 'usage-meter', key: 'scope' } as const, 'window')
46const expandedA = atom({ plugin: 'usage-meter', key: 'expanded' } as const, [])
47const allA = atom({ plugin: 'usage-meter', key: 'isAllShown' } as const, false)
48const hiddenA = atom({ plugin: 'usage-meter', key: 'isBandHidden' } as const, false)
49
50// ---------------------------------------------------------------- files
51
52let root = ''
53let sessionId = ''
54let viewMtime = -1
55let mineMtime = -1
56let mineFor = ''
57
58async function initPaths($: $T): Promise<void> {
59  if (!root) root = `${(await $.env.get('HOME')) ?? '.'}/.claude/usage-meter`
60}
61
62async function mtimeOf($: $T, path: string): Promise<number | null> {
63  try {
64    return (await $.fs.stat(path)).mtimeMs
65  } catch {
66    return null
67  }
68}
69
70/** Reads view.json and this session's detail file, only when they changed. */
71async function loadView($: $T): Promise<void> {
72  await initPaths($)
73  const vm = await mtimeOf($, `${root}/local/view.json`)
74  if (vm !== null && vm !== viewMtime) {
75    try {
76      const view = JSON.parse(String(await $.fs.read(`${root}/local/view.json`))) as View
77      viewMtime = vm
78      pulseRates = view.windows.find(w => w.kind === 'five_hour')?.rates ?? null
79      await update($, viewA, () => view)
80    } catch {
81      // mid-replace: the next tick reads it
82    }
83  }
84  if (!sessionId) return
85  const path = `${root}/local/sessions/${sessionId}.json`
86  const mm = await mtimeOf($, path)
87  if (mm !== null && (mm !== mineMtime || mineFor !== sessionId)) {
88    try {
89      const mine = JSON.parse(String(await $.fs.read(path))) as ScopeView
90      mineMtime = mm
91      mineFor = sessionId
92      await update($, mineA, () => mine)
93    } catch {
94      // try again next tick
95    }
96  }
97}
98
99/** live.json: Codex runs and live sessions on every machine, rewritten every few seconds. */
100let pulseMtime = -1
101let pulse: Pulse | null = null
102
103async function loadPulse($: $T): Promise<void> {
104  await initPaths($)
105  const m = await mtimeOf($, `${root}/local/live.json`)
106  if (m === null || m === pulseMtime) return
107  try {
108    const next = JSON.parse(String(await $.fs.read(`${root}/local/live.json`))) as Pulse
109    pulseMtime = m
110    pulse = next
111    await update($, pulseA, () => next)
112  } catch {
113    // mid-replace: the next tick reads it
114  }
115}
116
117/** The 5h window's learned points per plan-reference $, by model family (kept from view.json). */
118let pulseRates: Record<string, number | undefined> | null = null
119
120/** Codex calls this session has in flight (a Bash `codex exec` still running). */
121let codexCalls = 0
122
123function myRunningCodex(): CodexRun | undefined {
124  return pulse?.codex.runs.find(r => r.status === 'running' && r.parent?.sid === sessionId)
125}
126
127/** Asks the collector to re-read everything now, then picks up what it writes. */
128async function refreshNow($: $T): Promise<void> {
129  await initPaths($)
130  await $.fs.write(`${root}/local/refresh`, String(await $.clock.now()))
131  for (const ms of [800, 1800, 3500]) {
132    $.clock.after(ms, () => void Promise.all([loadView($), loadPulse($)]))
133  }
134}
135
136async function stopCodex($: $T, run: CodexRun): Promise<void> {
137  await initPaths($)
138  await $.fs.write(`${root}/local/stop/${run.id}.json`, JSON.stringify({ at: await $.clock.now() }))
139  $.ui.toast(`Asking Codex to stop: ${run.task.slice(0, 60)}`, { timeoutMs: 5000 })
140}
141
142// ---------------------------------------------------------------- plan-limit readings
143
144const samples: Lim[] = []
145const lastLim = new Map<string, Lim>()
146const alerted = new Set<string>()
147let isSamplesDirty = false
148let ccUsd: number | null = null
149
150function windowId(resetsAt: number): number {
151  return Math.round(resetsAt / 600_000)
152}
153
154/** Records a reading when it changes, and every ten minutes while unchanged so
155 * the collector knows the session was watching when a point was crossed. */
156function recordLim($: $T, now: number, w: SessionRateLimit): void {
157  const resets = w.resetsAt ? Date.parse(w.resetsAt) || 0 : 0
158  const prev = lastLim.get(w.kind)
159  const isSame = prev !== undefined && prev[2] === w.percentUsed && windowId(prev[3]) === windowId(resets)
160  if (isSame && now - prev[0] < HEARTBEAT) return
161  const row: Lim = [now, w.kind, w.percentUsed, resets]
162  samples.push(row)
163  if (samples.length > 3000) samples.splice(0, samples.length - 3000)
164  lastLim.set(w.kind, row)
165  isSamplesDirty = true
166  if (isSame) return
167  for (const at of ALERTS) {
168    const key = `${w.kind}:${windowId(resets)}:${at}`
169    if (w.percentUsed < at || alerted.has(key)) continue
170    alerted.add(key)
171    if (prev === undefined) continue
172    const left = resets > now ? `, resets in ${fmtDur(resets - now)}` : ''
173    $.ui.toast(`${LABELS[w.kind] ?? w.kind} limit at ${w.percentUsed}%${left}`, { timeoutMs: 8000 })
174  }
175}
176
177async function saveSamples($: $T): Promise<void> {
178  if (!isSamplesDirty || !sessionId || !root) return
179  isSamplesDirty = false
180  try {
181    await $.fs.write(`${root}/local/limits/${sessionId}.json`, JSON.stringify({ sessionId, samples }))
182  } catch (err) {
183    isSamplesDirty = true
184    $.ui.log(`usage-meter: could not save limit readings: ${String(err)}`, { to: 'debug' })
185  }
186}
187
188async function sample($: $T): Promise<void> {
189  const u = await $.session.usage()
190  const now = await $.clock.now()
191  if (u.cost && u.cost.usd !== ccUsd) {
192    ccUsd = u.cost.usd
193    await update($, ccA, () => ccUsd)
194  }
195  for (const w of u.rateLimits) recordLim($, now, w)
196  $.ui.status(statusText())
197}
198
199function statusText(): string {
200  const parts: string[] = []
201  for (const kind of ['five_hour', 'seven_day']) {
202    const x = lastLim.get(kind)
203    if (x) parts.push(`${LABELS[kind]} ${x[2]}%`)
204  }
205  if (ccUsd !== null) parts.push(`${fmtUsd(ccUsd)} session`)
206  return parts.length ? parts.join(' · ') : 'usage-meter'
207}
208
209/** Enough of the price list to show a turn's cost before the collector has it. */
210/** planCr: the cache-read price the plan-limit weights are learned against (API-only cuts left out). */
211/** prompt: tokens sent; Haiku 5.5 is priced 5x past 100k, and that gets a plan weight of its own. */
212function rateCard(model: string, prompt = 0): { label: string; fam: string; in: number; out: number; cr: number; planCr: number } {
213  const m = model.toLowerCase()
214  if (m.includes('fable') || m.includes('mythos')) {
215    const cr = m.includes('5-1') ? 0.25 : 1
216    return { label: 'Fable', fam: 'fable', in: 10, out: 50, cr, planCr: cr }
217  }
218  if (m.includes('opus-5-5')) return { label: 'Opus 5.5', fam: 'opus', in: 4, out: 20, cr: 0.2, planCr: 0.2 }
219  if (m.includes('opus')) return { label: 'Opus', fam: 'opus', in: 5, out: 25, cr: 0.5, planCr: 0.5 }
220  if (m.includes('sonnet-4')) return { label: 'Sonnet 4.x', fam: 'sonnet', in: 3, out: 15, cr: 0.3, planCr: 0.3 }
221  if (m.includes('sonnet-5-5')) return { label: 'Sonnet 5.5', fam: 'sonnet', in: 2, out: 10, cr: 0.1, planCr: 0.2 }
222  if (m.includes('sonnet')) return { label: 'Sonnet', fam: 'sonnet', in: 2, out: 10, cr: 0.2, planCr: 0.2 }
223  if (m.includes('haiku-5-5') && prompt > 100_000) return { label: 'Haiku 5.5', fam: 'haiku-long', in: 0.5, out: 2.5, cr: 0.05, planCr: 0.05 }
224  if (m.includes('haiku-5-5')) return { label: 'Haiku 5.5', fam: 'haiku', in: 0.1, out: 0.5, cr: 0.01, planCr: 0.01 }
225  if (m.includes('haiku')) return { label: 'Haiku', fam: 'haiku', in: 1, out: 5, cr: 0.1, planCr: 0.1 }
226  return { label: model || 'model', fam: 'other', in: 4, out: 20, cr: 0.2, planCr: 0.2 }
227}
228
229async function computeContext($: $T): Promise<void> {
230  const u = await $.session.usage({ breakdown: 'summary' })
231  const b = u.context.breakdown
232  if (!b) return
233  const servers = new Map<string, { tokens: number; tools: number }>()
234  for (const t of b.mcpTools) {
235    if (!t.isLoaded) continue
236    const s = servers.get(t.serverName) ?? { tokens: 0, tools: 0 }
237    s.tokens += t.tokens
238    s.tools += 1
239    servers.set(t.serverName, s)
240  }
241  const p = rateCard(await $.session.model())
242  const resend = u.context.tokens ?? null
243  const ctx: ContextView = {
244    model: p.label,
245    total: b.totalTokens,
246    max: b.rawMaxTokens,
247    categories: b.categories
248      .filter(c => c.kind === 'used' && c.tokens > 0)
249      .sort((a, c) => c.tokens - a.tokens)
250      .map(c => ({ name: c.name, tokens: c.tokens })),
251    mcp: [...servers.entries()]
252      .map(([server, s]) => ({ server, ...s }))
253      .sort((a, c) => c.tokens - a.tokens)
254      .slice(0, 8),
255    resend,
256    readUsd: resend === null ? null : (resend * p.cr) / 1e6,
257    writeUsd: resend === null ? null : (resend * p.in * 2) / 1e6,
258  }
259  await update($, contextA, () => ctx)
260}
261
262// ---------------------------------------------------------------- live turn (band)
263
264let live: Live | null = null
265let liveTurn = ''
266let mainTurn = ''
267const agentTurn = new Map<string, string>()
268const agentLabel = new Map<string, string>()
269const liveAgents = new Map<string, { label: string; usd: number; isRunning: boolean }>()
270
271async function pushLive($: $T): Promise<void> {
272  if (!live) return
273  const agents = [...liveAgents.values()].sort((a, b) => b.usd - a.usd).slice(0, 6)
274  const copy: Live = { ...live, tok: [live.tok[0], live.tok[1], live.tok[2], live.tok[3]], agents: agents.map(a => ({ ...a })) }
275  await update($, liveA, () => copy)
276}
277
278// ---------------------------------------------------------------- formatting
279
280function fmtTok(n: number): string {
281  if (n < 1000) return String(Math.round(n))
282  if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`
283  if (n < 1e9) return `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M`
284  return `${(n / 1e9).toFixed(1)}B`
285}
286
287function fmtUsd(n: number): string {
288  if (n < 100) return `$${n.toFixed(2)}`
289  if (n < 10_000) return `$${Math.round(n).toLocaleString('en-US')}`
290  return `$${(n / 1000).toFixed(1)}k`
291}
292
293/** Estimated shares of a limit: never shown more precisely than the data allows. */
294function fmtShare(n: number | null): string {
295  if (n === null) return ''
296  if (n < 0.1) return '<0.1%'
297  return `≈${n < 10 ? n.toFixed(1) : Math.round(n)}%`
298}
299
300function fmtDur(ms: number): string {
301  const s = Math.max(0, Math.round(ms / 1000))
302  if (s < 60) return `${s}s`
303  const m = Math.floor(s / 60)
304  if (m < 60) return `${m}m`
305  const hours = Math.floor(m / 60)
306  if (hours < 48) return `${hours}h ${String(m % 60).padStart(2, '0')}m`
307  return `${Math.floor(hours / 24)}d ${hours % 24}h`
308}
309
310function sumTok(t: Tokens): number {
311  return t[0] + t[1] + t[2] + t[3]
312}
313
314/** Tokens with what they mostly are: a turn's millions are the context re-read from cache. */
315function tokText(t: Tokens): string {
316  const total = sumTok(t)
317  const cached = total > 0 ? Math.round((t[2] / total) * 100) : 0
318  return `${fmtTok(total)} tok${cached >= 50 ? ` (${cached}% cached)` : ''}`
319}
320
321function clip(text: string, n: number): string {
322  const one = text.replace(/\s+/g, ' ').trim()
323  if (one.length <= n) return one
324  const cut = one.slice(0, Math.max(1, n - 1))
325  const space = cut.lastIndexOf(' ')
326  return `${space > n * 0.6 ? cut.slice(0, space) : cut}…`
327}
328
329function pctColor(pct: number): string {
330  return pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green'
331}
332
333function svgBar(pct: number): string {
334  const fill = pct >= 90 ? '#f85149' : pct >= 70 ? '#d29922' : '#3fb950'
335  const w = Math.max(0, Math.min(500, (pct / 100) * 500))
336  return (
337    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 10" width="2000" height="40" preserveAspectRatio="none">` +
338    `<rect width="500" height="10" rx="5" fill="#8b949e" fill-opacity="0.22"/>` +
339    (w > 0 ? `<rect width="${Math.max(10, w).toFixed(1)}" height="10" rx="5" fill="${fill}"/>` : '') +
340    `</svg>`
341  )
342}
343
344function svgHistory(days: HistoryDay[], hosts: string[]): string {
345  const max = Math.max(1, ...days.map(d => d.total))
346  const width = 700
347  const height = 150
348  const pad = 22
349  const bw = width / Math.max(1, days.length)
350  let out = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height + pad}" width="${width}" height="${height + pad}">`
351  days.forEach((d, i) => {
352    let y = height
353    hosts.forEach((host, j) => {
354      const usd = d.hosts[host] ?? 0
355      if (usd <= 0) return
356      const bar = (usd / max) * (height - 8)
357      y -= bar
358      out += `<rect x="${(i * bw + 4).toFixed(1)}" y="${y.toFixed(1)}" width="${(bw - 8).toFixed(1)}" height="${bar.toFixed(1)}" rx="3" fill="${HOST_COLORS[j % HOST_COLORS.length]}"/>`
359    })
360    if ((days.length - 1 - i) % 2 === 0) {
361      out += `<text x="${(i * bw + bw / 2).toFixed(1)}" y="${height + 16}" font-size="13" text-anchor="middle" fill="#8b949e" font-family="system-ui">${d.label.split(' ')[1] ?? ''}</text>`
362    }
363  })
364  return `${out}</svg>`
365}
366
367function paceLine(w: WindowView, now: number): { text: string; isBad: boolean } {
368  if (w.isReset) return { text: 'reset · waiting for the next reading', isBad: false }
369  const parts = [w.resetsAt ? `${fmtDur(w.resetsAt - now)} left` : '']
370  if (w.usdLeft !== null && w.usdLeft > 0) parts.push(`≈${fmtUsd(w.usdLeft)} to go`)
371  let isBad = false
372  if (w.fullAt !== null && w.resetsAt !== null && w.fullAt < w.resetsAt) {
373    parts.push(`runs out in ~${fmtDur(w.fullAt - now)}`)
374    isBad = true
375  } else if (w.atReset !== null) {
376    parts.push(`~${Math.round(w.atReset)}% at reset`)
377  }
378  return { text: parts.filter(Boolean).join(' · '), isBad }
379}
380
381/** What the "outside Claude Code" check found, in plain words. */
382const OUTSIDE: Record<WindowView['outside'], string> = {
383  unknown: 'Usage outside Claude Code (claude.ai, phone): too early to tell',
384  none: '✓ Claude Code accounts for all of this limit',
385  some: 'The rest came from outside Claude Code: claude.ai, the phone app or Cowork',
386}
387
388const CONFIDENCE: Record<string, string | undefined> = {
389  none: 'no estimate yet',
390  rough: 'rough estimate',
391  low: 'low confidence',
392  medium: 'medium confidence',
393  high: 'high confidence',
394}
395
396function summaryText(v: View | null, now: number): string {
397  if (!v) return 'No usage data yet: the collector writes it within seconds of starting.'
398  const lines = v.windows.map(w => `${w.label} limit ${w.pct}% · ${paceLine(w, now).text}`)
399  lines.push(`5h window ${fmtUsd(v.window.usd)} · last 30 days ${fmtUsd(v.monthUsd)}`)
400  return lines.join('\n')
401}
402
403// ---------------------------------------------------------------- hooks
404
405export const register: Register = on => {
406  on('session.start', async ($, e, next) => {
407    await initPaths($)
408    sessionId = await $.session.id()
409    await $.command.register({
410      name: 'burn',
411      description: 'Usage meter: plan limits, budget left, and where every token went',
412      argumentHint: '[text|band]',
413      immediate: true,
414    })
415    // Cheap: a stat or two. Reads view.json only when the collector rewrote it.
416    let tick = 0
417    $.clock.every(2000, () => {
418      tick += 1
419      void (async () => {
420        const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
421        if (isOpen || tick % 15 === 0) await loadView($)
422        const isCodexLive = codexCalls > 0 || myRunningCodex() !== undefined
423        if (isOpen || isCodexLive || tick % 15 === 0) await loadPulse($)
424        if (isCodexLive || isOpen) $.ui.invalidate('ui.render')
425        if (isOpen && tick % 5 === 0 && (await read($, tabA)) === 'context') await computeContext($)
426        if (tick % 2 === 0) await saveSamples($)
427      })()
428    })
429    void sample($).then(() => Promise.all([loadView($), loadPulse($)]))
430
431    return next(e)
432  })
433
434  on('turn.start', async ($, e, next) => {
435    const now = await $.clock.now()
436    mainTurn = e.turnId
437    liveTurn = e.turnId
438    liveAgents.clear()
439    live = { turnId: e.turnId, startedAt: now, endedAt: null, usd: 0, tok: [0, 0, 0, 0], reqs: 0, agents: [] }
440    await pushLive($)
441
442    return next(e)
443  })
444
445  on('agent.spawn', async ($, e, next) => {
446    const r = await next(e)
447    if (r.agentId) {
448      const turn = (e.parentAgentId ? agentTurn.get(e.parentAgentId) : undefined) ?? mainTurn
449      agentTurn.set(r.agentId, turn)
450      const label = e.description ? `${e.subagentType}: ${e.description}` : e.subagentType || 'subagent'
451      agentLabel.set(r.agentId, label)
452      if (turn === liveTurn && live) {
453        liveAgents.set(r.agentId, { label, usd: 0, isRunning: true })
454        await pushLive($)
455      }
456    }
457
458    return r
459  })
460
461  on('turn.step', async function* ($, e, next) {
462    const r = yield* next(e)
463    try {
464      const u = r.usage
465      if (u) {
466        const turn = e.agentId ? (agentTurn.get(e.agentId) ?? mainTurn) : e.turnId
467        if (live && turn === liveTurn) {
468          const p = rateCard(u.model || e.model, u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens)
469          const rest = (u.input_tokens * p.in + u.output_tokens * p.out + u.cache_creation_input_tokens * p.in * 2) / 1e6
470          const usd = rest + (u.cache_read_input_tokens * p.cr) / 1e6
471          const planUsd = rest + (u.cache_read_input_tokens * p.planCr) / 1e6
472          const k5 = pulseRates?.[p.fam] ?? pulseRates?.opus
473          live.usd += usd
474          if (k5 !== undefined) live.pts = (live.pts ?? 0) + planUsd * k5
475          live.tok[0] += u.input_tokens
476          live.tok[1] += u.output_tokens
477          live.tok[2] += u.cache_read_input_tokens
478          live.tok[3] += u.cache_creation_input_tokens
479          live.reqs += 1
480          if (e.agentId) {
481            const cur = liveAgents.get(e.agentId)
482            liveAgents.set(e.agentId, {
483              label: cur?.label ?? agentLabel.get(e.agentId) ?? 'subagent',
484              usd: (cur?.usd ?? 0) + usd,
485              isRunning: cur?.isRunning ?? true,
486            })
487          }
488          await pushLive($)
489        }
490        await sample($)
491      }
492    } catch (err) {
493      $.ui.log(`usage-meter: live update failed: ${String(err)}`, { to: 'debug' })
494    }
495
496    return r
497  })
498
499  // A Bash call that runs `codex exec`: the band follows that Codex run live.
500  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
501    if (!/\bcodex\s+exec\b/.test(e.command)) return next(e)
502    codexCalls += 1
503    try {
504      return await next(e)
505    } finally {
506      codexCalls = Math.max(0, codexCalls - 1)
507      void loadPulse($)
508    }
509  })
510
511  on('turn.complete', async ($, e, next) => {
512    const now = await $.clock.now()
513    if (e.agentId) {
514      const cur = liveAgents.get(e.agentId)
515      if (cur) {
516        cur.isRunning = false
517        await pushLive($)
518      }
519    } else if (live && live.turnId === e.turnId) {
520      live.endedAt = now
521      await pushLive($)
522      void saveSamples($)
523    }
524
525    return next(e)
526  })
527
528  on('session.measure', async ($, e, next) => {
529    const now = await $.clock.now()
530    if (e.cost && e.cost.usd !== ccUsd) {
531      ccUsd = e.cost.usd
532      await update($, ccA, () => ccUsd)
533    }
534    for (const w of e.rateLimits) recordLim($, now, w)
535    $.ui.status(statusText())
536
537    return next(e)
538  })
539
540  on('session.end', async ($, e, next) => {
541    await saveSamples($)
542    if (e.reason === 'clear') {
543      live = null
544      liveTurn = ''
545      liveAgents.clear()
546      sessionId = ''
547      ccUsd = null
548    }
549
550    return next(e)
551  })
552
553  on('command.run', { command: 'burn' }, async ($, e) => {
554    if (!sessionId) sessionId = await $.session.id()
555    const arg = e.args.trim()
556    if (arg === 'band') {
557      const isHidden = await read($, hiddenA)
558      await update($, hiddenA, () => !isHidden)
559      return { text: isHidden ? 'Band shown.' : 'Band hidden.' }
560    }
561    await Promise.all([loadView($), loadPulse($)])
562    if (arg === 'text') return { text: summaryText(await read($, viewA), await $.clock.now()) }
563    const opened = await $.ui.open({ id: PANE, title: 'Usage' })
564    if (!opened.isPlaced) return { text: summaryText(await read($, viewA), await $.clock.now()) }
565
566    return {}
567  })
568
569  // ---- the band above the prompt: this turn's burn, and Codex when it's running for this chat
570
571  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
572    if (e.props.hasSurvey || (await read($, hiddenA))) return next(e)
573    const lv = await read($, liveA)
574    const p = await read($, pulseA)
575    const run = p?.codex.runs.find(r => r.status === 'running' && r.parent?.sid === sessionId)
576    const v = await read($, viewA)
577    if (!lv && !run && !v) return next(e)
578    const { Box, Text, Button } = $.ui.resolve(e)
579    const now = await $.clock.now()
580    const W = e.props.bodyColumns
581
582    let turnRow: JSX.Element | null = null
583    if (lv) {
584      const isWorking = lv.endedAt === null
585      const r5 = v?.windows.find(w => w.kind === 'five_hour')?.rate ?? null
586      const running = lv.agents.filter(a => a.isRunning).length
587      // each request re-sends the whole conversation: its average size is the context
588      const context = lv.reqs > 0 ? (lv.tok[0] + lv.tok[2] + lv.tok[3]) / lv.reqs : 0
589      const parts = [
590        isWorking ? '● this turn' : 'last turn',
591        fmtUsd(lv.usd),
592        `${lv.reqs} request${lv.reqs === 1 ? '' : 's'}`,
593        tokText(lv.tok),
594        context >= 300_000 ? `context ${fmtTok(context)}` : '',
595        lv.pts !== undefined ? `${fmtShare(lv.pts)} of 5h` : r5 === null ? '' : `${fmtShare(lv.usd * r5)} of 5h`,
596        lv.agents.length ? `${lv.agents.length} subagent${lv.agents.length === 1 ? '' : 's'}${running ? ` (${running} running)` : ''}` : '',
597      ].filter(Boolean)
598      turnRow = (
599        <Box flexDirection="row" width={W}>
600          <Box flexGrow={1} flexShrink={1}>
601            <Text dimColor={!isWorking} wrap="truncate-end">
602              {parts.join(' · ')}
603            </Text>
604          </Box>
605          <Button key="details" label="details" plain dimColor onPress={() => void $.ui.open({ id: PANE, title: 'Usage' })} />
606          <Text> </Text>
607          <Button key="hide" label="hide" plain dimColor onPress={() => void update($, hiddenA, () => true)} />
608        </Box>
609      )
610    }
611
612    let codexRow: JSX.Element | null = null
613    if (run) {
614      const doing = run.current ? `▶ ${run.current}` : run.progress || 'thinking'
615      const line = `◆ Codex · ${doing}`
616      const meta = ` · ${fmtDur(now - (run.started || now))} · ${fmtTok(run.tokens[0] + run.tokens[2])} tok`
617      codexRow = (
618        <Box flexDirection="row" width={W}>
619          <Box flexGrow={1} flexShrink={1}>
620            <Text color="cyan" wrap="truncate-end">
621              {clip(line, Math.max(20, W - meta.length - 16))}
622              <Text dimColor>{meta}</Text>
623            </Text>
624          </Box>
625          <Button
626            key="codex-watch"
627            label="watch"
628            plain
629            dimColor
630            onPress={() => void update($, tabA, () => 'codex').then(() => $.ui.open({ id: PANE, title: 'Usage' }))}
631          />
632          {run.isSelf && <Text> </Text>}
633          {run.isSelf && <Button key="codex-stop" label="stop" plain dimColor onPress={() => void stopCodex($, run)} />}
634        </Box>
635      )
636    }
637
638    // Before the first turn: a slim meter, so the analysis is always one click away
639    // (the status line is plain text and can't be clicked).
640    let meterRow: JSX.Element | null = null
641    if (!turnRow && v) {
642      const cw = p?.codex.limits?.primary
643      const cc = await read($, ccA)
644      const parts = [
645        ...v.windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day').map(w => `${w.label} ${w.pct}%`),
646        cw ? `Codex ${Math.round(cw.used_percent)}%` : '',
647        cc !== null ? `${fmtUsd(cc)} session` : '',
648      ].filter(Boolean)
649      meterRow = (
650        <Box flexDirection="row" width={W}>
651          <Box flexGrow={1} flexShrink={1}>
652            <Text dimColor wrap="truncate-end">
653              {parts.join(' · ')}
654            </Text>
655          </Box>
656          <Button key="details" label="open usage" plain dimColor onPress={() => void $.ui.open({ id: PANE, title: 'Usage' })} />
657          <Text> </Text>
658          <Button key="hide" label="hide" plain dimColor onPress={() => void update($, hiddenA, () => true)} />
659        </Box>
660      )
661    }
662
663    return (
664      <Box flexDirection="column" width={W}>
665        {codexRow}
666        {turnRow ?? meterRow}
667      </Box>
668    )
669  })
670
671  // ---- the pane
672
673  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
674    const els = $.ui.resolve(e)
675    const { Box, Text, Button } = els
676    // chosen by surface: the terminal draws no Svg and the mobile app no Select
677    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
678    const Select = e.surface !== 'mobile' && 'Select' in els ? els.Select : undefined
679    const v = await read($, viewA)
680    const W = Math.max(28, e.props.bodyColumns)
681
682    if (!v) {
683      return (
684        <Box flexDirection="column" width={W}>
685          <Text bold>Starting up</Text>
686          <Text dimColor wrap="wrap">
687            Waiting for the collector's first summary. If this stays, the collector isn't running: see the README at github.com/rohit3a/usage-meter.
688          </Text>
689        </Box>
690      )
691    }
692    const tab = await read($, tabA)
693    const scope = await read($, scopeA)
694    const now = await $.clock.now()
695
696    // ---- building blocks. Proportional fonts on the desktop: alignment comes
697    // from flex boxes, never from spaces.
698    const Line = (p: { left: string; right?: string; bold?: boolean; dim?: boolean; color?: string; indent?: number; rightDim?: boolean }) => (
699      <Box flexDirection="row" width={W - (p.indent ?? 0)} marginLeft={p.indent ?? 0}>
700        <Box flexGrow={1} flexShrink={1}>
701          <Text bold={p.bold} dimColor={p.dim} wrap="truncate-end">
702            {p.left}
703          </Text>
704        </Box>
705        {p.right !== undefined && (
706          <Box flexShrink={0} marginLeft={1}>
707            <Text bold={p.bold} dimColor={p.rightDim ?? p.dim} color={p.color}>
708              {p.right}
709            </Text>
710          </Box>
711        )}
712      </Box>
713    )
714    const Note = (p: { text: string; color?: string; indent?: number; wrap?: boolean }) => (
715      <Box width={W - (p.indent ?? 0)} marginLeft={p.indent ?? 0}>
716        <Text dimColor={!p.color} color={p.color} wrap={p.wrap ? 'wrap' : 'truncate-end'}>
717          {p.text}
718        </Text>
719      </Box>
720    )
721    const Section = (p: { title: string; right?: string }) => (
722      <Box flexDirection="column" marginTop={1}>
723        <Line left={p.title.toUpperCase()} right={p.right} bold dim rightDim={false} />
724      </Box>
725    )
726    const Bar = (p: { pct: number }) =>
727      Svg ? (
728        <Box width={W}>
729          <Svg source={svgBar(p.pct)} alt={`${p.pct}% used`} />
730        </Box>
731      ) : (
732        <Text color={pctColor(p.pct)}>
733          {'━'.repeat(Math.max(0, Math.min(W, Math.round((p.pct / 100) * W)))) +
734            '─'.repeat(Math.max(0, W - Math.round((p.pct / 100) * W)))}
735        </Text>
736      )
737    const share = (row: ViewRow) => fmtShare(scope === 'week' ? row.p7 : row.p5)
738    const meta = (row: ViewRow) =>
739      [share(row), tokText(row.tok), row.ms > 0 ? fmtDur(row.ms) : '', row.sub].filter(Boolean).join(' · ')
740    const Row = (p: { row: ViewRow }) => (
741      <Box flexDirection="column">
742        <Line left={p.row.label} right={fmtUsd(p.row.usd)} />
743        <Note text={meta(p.row)} />
744      </Box>
745    )
746    const Compact = (p: { row: ViewRow; total: number }) => (
747      <Line left={p.row.label} right={`${fmtUsd(p.row.usd)} · ${p.total > 0 ? Math.round((p.row.usd / p.total) * 100) : 0}%`} rightDim={false} />
748    )
749
750    const Para = (p: { text: string; indent?: number }) => (
751      <Box width={W - (p.indent ?? 0)} marginLeft={p.indent ?? 0}>
752        <Text wrap="wrap">{p.text}</Text>
753      </Box>
754    )
755    const stepIcon = (k: string, ok: boolean | null) =>
756      k === 'cmd' ? (ok === false ? '✕' : '$') : k === 'edit' ? '✎' : k === 'mcp' ? '⚙' : k === 'agent' ? '↳' : '·'
757    const statusIcon: Record<CodexRun['status'], string> = { running: '●', done: '✓', aborted: '✕', stopped: '⏹', stale: '⚠', idle: '○' }
758    const statusWord: Record<CodexRun['status'], string> = {
759      running: 'running', done: 'finished', aborted: 'interrupted', stopped: 'stopped by you', stale: 'stopped responding', idle: 'idle',
760    }
761    const originOf = (r: CodexRun) =>
762      r.origin === 'exec' ? (r.parent ? 'started by Claude' : 'codex exec') : r.origin === 'app' ? 'Codex app' : r.origin || 'Codex'
763
764    // ---- header: the limits, always on top
765    const limits = v.windows.map(w => {
766      const pace = paceLine(w, now)
767      return (
768        <Box flexDirection="column" marginBottom={1}>
769          <Line left={`${w.label} limit`} right={`${w.pct}%`} bold color={pctColor(w.pct)} />
770          <Bar pct={w.pct} />
771          <Note text={pace.text} color={pace.isBad ? 'red' : undefined} />
772        </Box>
773      )
774    })
775
776    // ---- navigation: one row of dropdowns where the surface has them
777    const tabs: [Tab, string][] = [
778      ['overview', 'Overview'],
779      ['sessions', 'Sessions'],
780      ['codex', 'Codex'],
781      ['threads', 'Threads'],
782      ['tasks', 'Tasks'],
783      ['breakdown', 'Breakdown'],
784      ['history', 'History'],
785      ['accuracy', 'Accuracy'],
786      ['context', 'Context'],
787    ]
788    const scopes: [Scope, string][] = [
789      ['window', '5h window'],
790      ['week', '7 days'],
791      ['session', 'This session'],
792    ]
793    const usesScope = tab === 'threads' || tab === 'tasks' || tab === 'breakdown'
794    // a long list shown in full folds back when the view or the scope changes
795    const pickTab = (t: string) => {
796      void update($, allA, () => false)
797      void update($, tabA, () => t as Tab).then(() => (t === 'context' ? computeContext($) : loadView($)))
798    }
799    const pickScope = (x: string) => {
800      void update($, allA, () => false)
801      void update($, scopeA, () => x as Scope)
802    }
803    const age = Math.max(0, now - Math.max(v.builtAt, (await read($, pulseA))?.at ?? 0))
804    const refresh = (
805      <Box flexDirection="row" flexShrink={0} marginLeft={1}>
806        <Text dimColor>{age < 10_000 ? 'now ' : `${fmtDur(age)} ago `}</Text>
807        <Button key="refresh" label="↻ Refresh" plain onPress={() => void refreshNow($)} />
808      </Box>
809    )
810    const nav = Select ? (
811      <Box flexDirection="row" width={W} flexWrap="wrap">
812        <Box marginRight={2}>
813          <Select key="tab" options={tabs.map(([value, label]) => ({ value, label }))} value={tab} onSelect={pickTab} />
814        </Box>
815        {usesScope && <Select key="scope" options={scopes.map(([value, label]) => ({ value, label }))} value={scope} onSelect={pickScope} />}
816        <Box flexGrow={1} />
817        {refresh}
818      </Box>
819    ) : (
820      <Box flexDirection="column" width={W}>
821        <Box flexDirection="row" flexWrap="wrap">
822          {tabs.map(([t, label]) => (
823            <Button key={`tab-${t}`} label={label} plain variant={t === tab ? 'primary' : undefined} dimColor={t !== tab} onPress={() => pickTab(t)} />
824          ))}
825        </Box>
826        {refresh}
827        {usesScope && (
828          <Box flexDirection="row" flexWrap="wrap">
829            {scopes.map(([x, label]) => (
830              <Button key={`scope-${x}`} label={label} plain variant={x === scope ? 'primary' : undefined} dimColor={x !== scope} onPress={() => pickScope(x)} />
831            ))}
832          </Box>
833        )}
834      </Box>
835    )
836
837    // ---- Codex's own plan limit, and the live sessions at a glance
838    const pl = await read($, pulseA)
839    const cl = pl?.codex.limits
840    const cwLabel = (cw: CodexWindow) =>
841      cw.window_minutes >= 10_000 ? 'week' : cw.window_minutes === 300 ? '5h' : `${Math.round(cw.window_minutes / 60)}h`
842    const credits = cl?.credits?.balance ? Math.round(Number(cl.credits.balance)) : null
843    const codexHeader = [cl?.primary, cl?.secondary]
844      .filter((cw): cw is CodexWindow => !!cw)
845      .map(cw => (
846        <Box flexDirection="column" marginBottom={1}>
847          <Line left={`Codex ${cwLabel(cw)}`} right={`${Math.round(cw.used_percent)}%`} bold color={pctColor(cw.used_percent)} />
848          <Bar pct={cw.used_percent} />
849          <Note
850            text={[
851              `${fmtDur(cw.resets_at * 1000 - now)} left`,
852              cl?.plan_type ? `${cl.plan_type[0]?.toUpperCase() ?? ''}${cl.plan_type.slice(1)} plan` : '',
853              credits !== null ? `${credits.toLocaleString('en-US')} credits` : '',
854            ]
855              .filter(Boolean)
856              .join(' · ')}
857          />
858        </Box>
859      ))
860    const fleet: FleetEntry[] = pl?.fleet ?? []
861    const count = (state: FleetEntry['state']) => fleet.filter(f => f.state === state).length
862    const codexRunning = (pl?.codex.runs ?? []).filter(r => r.status === 'running')
863    const summary = [
864      count('needs-you') ? `${count('needs-you')} need you` : '',
865      count('working') ? `${count('working')} working` : '',
866      count('your-turn') ? `${count('your-turn')} your turn` : '',
867      codexRunning.length ? `${codexRunning.length} Codex` : '',
868    ].filter(Boolean)
869    const fleetSummary =
870      summary.length > 0 ? (
871        <Box marginBottom={1}>
872          <Button
873            key="fleet-summary"
874            label={`Sessions: ${summary.join(' · ')}`}
875            plain
876            variant={count('needs-you') ? 'primary' : undefined}
877            onPress={() => pickTab('sessions')}
878          />
879        </Box>
880      ) : null
881
882    const mine = await read($, mineA)
883    const scoped: ScopeView | null = scope === 'session' ? mine : scope === 'week' ? v.week : v.window
884    const cc = await read($, ccA)
885    const mineUsd = mine?.usd ?? v.sessionUsd[sessionId] ?? null
886    const ccCheck =
887      cc === null || mineUsd === null
888        ? null
889        : Math.abs(cc - mineUsd) <= Math.max(0.05, cc * 0.03)
890          ? '✓ matches Claude Code’s own total'
891          : cc > mineUsd
892            ? `Claude Code says ${fmtUsd(cc)} (collector catching up)`
893            : `Claude Code says ${fmtUsd(cc)}`
894    const w5 = v.windows.find(w => w.kind === 'five_hour')
895    const scopeTitle = scope === 'session' ? 'this session' : scope === 'week' ? 'last 7 days' : '5h window'
896
897    let body: JSX.Element
898    if (tab === 'overview') {
899      const machines = v.window.dims.machines ?? []
900      const plan = v.config.plan
901      const value = plan?.usdPerMonth
902        ? `${plan.name ?? 'Plan'} · ${(v.monthUsd / plan.usdPerMonth).toFixed(1)}× its ${fmtUsd(plan.usdPerMonth)}/mo price`
903        : (plan?.name ?? '')
904      body = (
905        <Box flexDirection="column">
906          <Section title="Where the 5h window went" right={fmtUsd(v.window.usd)} />
907          {machines.length === 0 && <Note text="Nothing in this window yet." />}
908          {machines.map(row => (
909            <Line left={row.label} right={`${fmtUsd(row.usd)}${row.p5 !== null ? ` · ${fmtShare(row.p5)}` : ''}`} rightDim={false} />
910          ))}
911          {w5?.outside === 'some' && w5.untrackedPct !== null && (
912            <Line left="Outside Claude Code" right={fmtShare(w5.untrackedPct)} rightDim={false} />
913          )}
914          {w5 && <Note text={OUTSIDE[w5.outside]} wrap />}
915          {w5?.rate ? <Note text={`1% of 5h ≈ ${fmtUsd(1 / w5.rate)} of Opus usage · ${CONFIDENCE[w5.confidence] ?? ''}`} /> : null}
916
917          <Section title="This session" right={mineUsd !== null ? fmtUsd(mineUsd) : undefined} />
918          {mine ? <Note text={`${mine.reqs} requests · ${tokText(mine.tok)}`} /> : <Note text="Appears after its first response." />}
919          {ccCheck && <Note text={ccCheck} />}
920
921          <Section title="Top tasks · 5h" />
922          {v.window.tasks.length === 0 && <Note text="none yet" />}
923          {v.window.tasks.slice(0, 4).map(row => (
924            <Row row={row} />
925          ))}
926
927          <Section title="Last 30 days" right={fmtUsd(v.monthUsd)} />
928          {value && <Note text={value} />}
929          <Note text="value at API list prices" />
930        </Box>
931      )
932    } else if (tab === 'sessions') {
933      const groups: [FleetEntry['state'], string][] = [
934        ['needs-you', 'Needs you'],
935        ['working', 'Working'],
936        ['your-turn', 'Your turn'],
937      ]
938      body = (
939        <Box flexDirection="column">
940          {fleet.length === 0 && <Note text="No live Claude sessions found." />}
941          {groups.map(([state, title]) => {
942            const rows = fleet.filter(f => f.state === state).sort((a, b) => b.since - a.since)
943            if (rows.length === 0) return null
944            return (
945              <Box flexDirection="column">
946                <Section title={`${title} · ${rows.length}`} />
947                {rows.map(f => {
948                  const usd = v.sessionUsd[f.sid]
949                  const age =
950                    state === 'working' ? `working ${fmtDur(now - f.since)}` : state === 'needs-you' ? `waiting ${fmtDur(now - f.since)}` : `idle ${fmtDur(now - f.since)}`
951                  return (
952                    <Box flexDirection="column" marginTop={1}>
953                      <Line left={`${f.sid === sessionId ? '◉ ' : ''}${f.name || f.project}`} right={usd !== undefined ? fmtUsd(usd) : undefined} bold rightDim />
954                      <Note text={f.detail} color={state === 'needs-you' ? 'yellow' : undefined} />
955                      <Note text={[f.host, f.project, age].join(' · ')} />
956                    </Box>
957                  )
958                })}
959              </Box>
960            )
961          })}
962          {codexRunning.length > 0 && (
963            <Box flexDirection="column">
964              <Section title={`Codex running · ${codexRunning.length}`} />
965              {codexRunning.map(r => (
966                <Box flexDirection="column" marginTop={1}>
967                  <Line left={`◆ ${r.project}`} right={fmtDur(now - r.started)} bold />
968                  <Note text={r.current ? `▶ ${r.current}` : r.progress || r.task} color="cyan" />
969                  <Note text={`${originOf(r)} · ${r.host}`} />
970                </Box>
971              ))}
972            </Box>
973          )}
974          <Note text="◉ is this chat. Your turn: finished and waiting for your next message." wrap />
975        </Box>
976      )
977    } else if (tab === 'codex') {
978      const expanded = await read($, expandedA)
979      const isAll = await read($, allA)
980      const toggle = (id: string) => () =>
981        void update($, expandedA, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
982      const runs = pl?.codex.runs ?? []
983      const recent = runs.filter(r => r.status !== 'running')
984      const shown = isAll ? recent : recent.slice(0, 10)
985      const parentName = (sid: string) =>
986        fleet.find(f => f.sid === sid)?.name || v.week.threads.find(t => t.id === sid)?.label || 'a Claude session'
987      body = (
988        <Box flexDirection="column">
989          {!pl && <Note text="Waiting for the collector’s first Codex scan…" />}
990          <Section title={`Running · ${codexRunning.length}`} />
991          {codexRunning.length === 0 && <Note text="Nothing running right now." />}
992          {codexRunning.map(r => (
993            <Box flexDirection="column" marginTop={1}>
994              <Line left={`● ${r.project}`} right={fmtDur(now - r.started)} bold color="cyan" />
995              <Note text={[r.model, r.tier === 'priority' ? 'priority' : '', originOf(r), r.isSelf ? '' : `on ${r.host}`].filter(Boolean).join(' · ')} />
996              <Para text={clip(r.task, 240)} />
997              {r.parent && <Note text={`↳ from “${parentName(r.parent.sid)}”`} />}
998              {r.current ? (
999                <Line left={`▶ ${r.current}`} right={`${fmtDur(now - r.currentSince)}`} color="cyan" rightDim={false} />
1000              ) : r.progress ? (
1001                <Note text={`“${r.progress}”`} wrap />
1002              ) : null}
1003              {r.steps.slice(-5).map(st => (
1004                <Line left={`${stepIcon(st.k, st.ok)} ${st.t}`} right={`${fmtDur(now - st.ts)} ago`} dim />
1005              ))}
1006              <Note
1007                text={`${r.counts.cmds} commands · ${r.counts.edits} edits${r.counts.mcp ? ` · ${r.counts.mcp} tool calls` : ''} · ${fmtTok(r.tokens[0] + r.tokens[2])} tok`}
1008              />
1009              {r.isSelf && (
1010                <Box marginTop={1}>
1011                  <Button key={`stop-${r.id}`} label={r.stopAsked ? 'Stopping…' : 'Stop this run'} onPress={() => void stopCodex($, r)} />
1012                </Box>
1013              )}
1014            </Box>
1015          ))}
1016          <Section title={`Recent · last 2 days`} />
1017          {recent.length === 0 && <Note text="No finished runs yet." />}
1018          {shown.map(r => {
1019            const isOpen = expanded.includes(`cx-${r.id}`)
1020            const took = r.ended && r.started ? fmtDur(r.ended - r.started) : ''
1021            return (
1022              <Box flexDirection="column" marginTop={1}>
1023                <Box flexDirection="row" width={W}>
1024                  <Box flexGrow={1} flexShrink={1}>
1025                    <Button
1026                      key={`cx-${r.id}`}
1027                      label={`${isOpen ? '▾' : '▸'} ${statusIcon[r.status]} ${clip(r.task || r.project, Math.max(10, W - took.length - 8))}`}
1028                      plain
1029                      onPress={toggle(`cx-${r.id}`)}
1030                    />
1031                  </Box>
1032                  <Box flexShrink={0} marginLeft={1}>
1033                    <Text dimColor>{took}</Text>
1034                  </Box>
1035                </Box>
1036                <Note
1037                  text={[r.project, originOf(r), statusWord[r.status], `${fmtDur(now - (r.ended || r.updated))} ago`].join(' · ')}
1038                  indent={2}
1039                />
1040                {isOpen && (
1041                  <Box flexDirection="column" marginLeft={2}>
1042                    {r.parent && <Note text={`↳ from “${parentName(r.parent.sid)}”`} />}
1043                    {r.final ? <Para text={r.final} /> : <Note text={r.progress || 'No final message.'} wrap />}
1044                    {r.steps.slice(-8).map(st => (
1045                      <Line left={`${stepIcon(st.k, st.ok)} ${st.t}`} dim />
1046                    ))}
1047                    <Note text={`${r.counts.cmds} commands · ${r.counts.edits} edits · ${fmtTok(r.tokens[0] + r.tokens[2])} tok · ${r.model}`} />
1048                  </Box>
1049                )}
1050              </Box>
1051            )
1052          })}
1053          {recent.length > shown.length && (
1054            <Box marginTop={1}>
1055              <Button key="more" label={`Show ${recent.length - shown.length} more`} plain onPress={() => void update($, allA, () => true)} />
1056            </Box>
1057          )}
1058        </Box>
1059      )
1060    } else if (tab === 'threads') {
1061      const expanded = await read($, expandedA)
1062      const toggle = (id: string) => () =>
1063        void update($, expandedA, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
1064      const threads: ThreadRow[] = scoped?.threads ?? []
1065      body = (
1066        <Box flexDirection="column">
1067          <Section title={`Threads · ${scopeTitle}`} right={scoped ? fmtUsd(scoped.usd) : undefined} />
1068          {threads.length === 0 && <Note text="Nothing here yet." />}
1069          {threads.map(t => {
1070            const isOpen = expanded.includes(t.id)
1071            const right = fmtUsd(t.usd)
1072            return (
1073              <Box flexDirection="column" marginTop={1}>
1074                <Box flexDirection="row" width={W}>
1075                  <Box flexGrow={1} flexShrink={1}>
1076                    <Button
1077                      key={`open-${t.id}`}
1078                      label={`${isOpen ? '▾' : '▸'} ${clip(t.label, Math.max(10, W - right.length - 4))}`}
1079                      plain
1080                      onPress={toggle(t.id)}
1081                    />
1082                  </Box>
1083                  <Box flexShrink={0} marginLeft={1}>
1084                    <Text bold>{right}</Text>
1085                  </Box>
1086                </Box>
1087                <Note text={`${meta(t)}${t.agents ? ` · ${t.agents} subagent${t.agents === 1 ? '' : 's'}` : ''}`} indent={2} />
1088                {isOpen && t.kids.length > 0 && <Note text="AGENTS" indent={2} />}
1089                {isOpen &&
1090                  t.kids.map(k => (
1091                    <Line
1092                      left={`${'  '.repeat(Math.max(0, (k.depth ?? 1) - 1))}↳ ${k.label}`}
1093                      right={`${fmtUsd(k.usd)}${share(k) ? ` · ${share(k)}` : ''}`}
1094                      indent={2}
1095                      dim
1096                    />
1097                  ))}
1098                {isOpen && t.tasks.length > 0 && <Note text="TASKS" indent={2} />}
1099                {isOpen &&
1100                  t.tasks.map(k => (
1101                    <Line left={`• ${k.label}`} right={fmtUsd(k.usd)} indent={2} dim />
1102                  ))}
1103                {isOpen && (pl?.codex.runs ?? []).some(r => r.parent?.sid === t.id) && <Note text="CODEX" indent={2} />}
1104                {isOpen &&
1105                  (pl?.codex.runs ?? [])
1106                    .filter(r => r.parent?.sid === t.id)
1107                    .slice(0, 8)
1108                    .map(r => (
1109                      <Line
1110                        left={`◆ ${statusIcon[r.status]} ${r.task || r.project}`}
1111                        right={r.status === 'running' ? 'running' : r.ended && r.started ? fmtDur(r.ended - r.started) : ''}
1112                        indent={2}
1113                        dim={r.status !== 'running'}
1114                      />
1115                    ))}
1116              </Box>
1117            )
1118          })}
1119        </Box>
1120      )
1121    } else if (tab === 'tasks') {
1122      const isAll = await read($, allA)
1123      const tasks = scoped?.tasks ?? []
1124      const shown = isAll ? tasks : tasks.slice(0, 12)
1125      body = (
1126        <Box flexDirection="column">
1127          <Section title={`Tasks · ${scopeTitle}`} right={scoped ? fmtUsd(scoped.usd) : undefined} />
1128          {tasks.length === 0 && <Note text="Nothing here yet." />}
1129          {shown.map(row => (
1130            <Box marginTop={1}>
1131              <Row row={row} />
1132            </Box>
1133          ))}
1134          {tasks.length > shown.length && (
1135            <Box marginTop={1}>
1136              <Button key="more" label={`Show ${tasks.length - shown.length} more`} plain onPress={() => void update($, allA, () => true)} />
1137            </Box>
1138          )}
1139        </Box>
1140      )
1141    } else if (tab === 'breakdown') {
1142      const dims = scoped?.dims ?? {}
1143      const total = scoped?.usd ?? 0
1144      const sections: [string, string][] = [
1145        ['projects', 'Projects'],
1146        ['models', 'Models'],
1147        ['effort', 'Effort level'],
1148        ['agents', 'Main thread vs subagents'],
1149        ['skills', 'Skills'],
1150        ['mcp', 'MCP servers'],
1151        ['machines', 'Machines'],
1152      ]
1153      const cache = scoped?.cache
1154      body = (
1155        <Box flexDirection="column">
1156          <Note text={`${fmtUsd(total)} in ${scopeTitle} · % is the share of that`} />
1157          {sections.map(([key, title]) => {
1158            const rows = dims[key] ?? []
1159            if (rows.length === 0) return null
1160            return (
1161              <Box flexDirection="column">
1162                <Section title={title} />
1163                {rows.map(row => (
1164                  <Compact row={row} total={total} />
1165                ))}
1166              </Box>
1167            )
1168          })}
1169          {cache && cache.read + cache.write > 0 && (
1170            <Box flexDirection="column">
1171              <Section title="Prompt cache" />
1172              <Line left="Read from cache (cheap)" right={`${fmtTok(cache.read)} tok`} />
1173              <Line left="Written to cache" right={`${fmtTok(cache.write)} tok`} />
1174              <Line left="Generated" right={`${fmtTok(cache.out)} tok`} />
1175              {cache.rebuilds > 0 && (
1176                <Note text={`${cache.rebuilds} requests re-sent a large context (cache expired, compaction or a model switch): ${fmtUsd(cache.rebuildUsd)}`} wrap />
1177              )}
1178            </Box>
1179          )}
1180        </Box>
1181      )
1182    } else if (tab === 'history') {
1183      const hostNames = [...new Set(v.history.flatMap(d => Object.keys(d.hosts)))].sort()
1184      const days = v.history.slice(-7).reverse()
1185      const max = Math.max(1, ...v.history.map(d => d.total))
1186      body = (
1187        <Box flexDirection="column">
1188          <Section title="Last 14 days" right={fmtUsd(v.history.reduce((n, d) => n + d.total, 0))} />
1189          {Svg ? (
1190            <Box flexDirection="column">
1191              <Box width={W} marginTop={1}>
1192                <Svg source={svgHistory(v.history, hostNames)} alt="Daily spend for the last 14 days, by machine" />
1193              </Box>
1194              <Box flexDirection="row" flexWrap="wrap">
1195                {hostNames.map((host, i) => (
1196                  <Box marginRight={2}>
1197                    <Text color={HOST_COLORS[i % HOST_COLORS.length]}>■ </Text>
1198                    <Text dimColor>{host}</Text>
1199                  </Box>
1200                ))}
types/index.d.ts 221 lines
1/** Token counts: uncached input, output, cache read, cache write. */
2export type Tokens = [number, number, number, number]
3
4/** One line of a breakdown, as the collector's view.json writes it. */
5export type ViewRow = {
6  label: string
7  sub: string
8  usd: number
9  tok: Tokens
10  reqs: number
11  ms: number
12  /** Share of the 5-hour / 7-day limit, from the %-per-$ rate. */
13  p5: number | null
14  p7: number | null
15  depth?: number
16}
17
18export type ThreadRow = ViewRow & {
19  id: string
20  agents: number
21  kids: ViewRow[]
22  tasks: ViewRow[]
23}
24
25export type ScopeView = {
26  usd: number
27  tok: Tokens
28  reqs: number
29  sessions: number
30  threads: ThreadRow[]
31  tasks: ViewRow[]
32  dims: Record<string, ViewRow[] | undefined>
33  cache: { read: number; write: number; fresh: number; out: number; rebuilds: number; rebuildUsd: number }
34}
35
36export type Stretch = {
37  from: number
38  to: number
39  pctFrom: number
40  pctTo: number
41  usd: number
42  usdPerPoint: number
43  opusShare: number
44  /** the model family that made up most of the stretch */
45  mostly?: string
46}
47
48/** How much of a limit a plan-reference dollar of each model family uses, learned from crossings. */
49export type ModelWeight = { family: string; label: string; weight: number; points: number }
50
51export type WindowView = {
52  kind: string
53  label: string
54  pct: number
55  resetsAt: number | null
56  isReset: boolean
57  rate: number | null
58  /** points per plan-reference $ by model family (opus, sonnet, haiku, haiku-long, fable, other); Haiku 5.5 prompts over 100k are haiku-long */
59  rates?: Record<string, number | undefined> | null
60  models?: ModelWeight[]
61  rateFrom: 'learned' | 'estimate' | 'none'
62  confidence: 'none' | 'rough' | 'low' | 'medium' | 'high'
63  stretches: Stretch[]
64  spread: number | null
65  readings: number
66  usdInWindow: number | null
67  untrackedPct: number | null
68  /** Whether anything outside Claude Code used this limit: not measurable yet, none, or some. */
69  outside: 'unknown' | 'none' | 'some'
70  burnPerHour: number | null
71  atReset: number | null
72  fullAt: number | null
73  usdLeft: number | null
74  typicalSessions: number | null
75}
76
77export type HistoryDay = {
78  date: string
79  label: string
80  hosts: Record<string, number | undefined>
81  total: number
82  top: [string, number][]
83}
84
85export type HostInfo = {
86  host: string
87  isSelf: boolean
88  updatedAt: number
89  syncError: string | null
90  subscription: string
91}
92
93/** What the collector writes to ~/.claude/usage-meter/local/view.json. */
94export type View = {
95  v: number
96  builtAt: number
97  host: string
98  windows: WindowView[]
99  window: ScopeView
100  week: ScopeView
101  history: HistoryDay[]
102  monthUsd: number
103  typicalSessionUsd: number
104  biggestSessionUsd: number
105  sessionUsd: Record<string, number | undefined>
106  accuracy: { requestsByHost: Record<string, number | undefined> }
107  hosts: HostInfo[]
108  config: { plan?: { name?: string; usdPerMonth?: number } }
109}
110
111export type CodexStep = { k: 'cmd' | 'edit' | 'mcp' | 'agent' | 'note'; t: string; ts: number; ok: boolean | null }
112
113/** One Codex run, as the collector reads it from ~/.codex/sessions. */
114export type CodexRun = {
115  id: string
116  host: string
117  started: number
118  updated: number
119  ended: number
120  status: 'running' | 'done' | 'aborted' | 'stopped' | 'stale' | 'idle'
121  origin: string
122  cwd: string
123  project: string
124  model: string
125  tier: string
126  task: string
127  progress: string
128  current: string | null
129  currentSince: number
130  steps: CodexStep[]
131  counts: { cmds: number; edits: number; mcp: number; agents: number }
132  tokens: [number, number, number, number]
133  final: string
134  reason: string
135  parent: { sid: string; prompt: string; host: string } | null
136  pid?: number | null
137  stopAsked?: number
138  isSelf: boolean
139}
140
141export type CodexWindow = { used_percent: number; window_minutes: number; resets_at: number }
142export type CodexLimits = {
143  at: number
144  plan_type?: string | null
145  primary?: CodexWindow | null
146  secondary?: CodexWindow | null
147  credits?: { has_credits: boolean; unlimited: boolean; balance: string | null } | null
148}
149
150/** One live Claude session, from Claude Code's own status file and its transcript. */
151export type FleetEntry = {
152  sid: string
153  host: string
154  pid: number
155  name: string
156  cwd: string
157  project: string
158  entry: string
159  kind: string
160  startedAt: number
161  since: number
162  state: 'working' | 'needs-you' | 'your-turn' | 'idle'
163  detail: string
164  lastAt: number
165  isSelf: boolean
166}
167
168/** What the collector writes to ~/.claude/usage-meter/local/live.json, every few seconds. */
169export type Pulse = {
170  at: number
171  codex: { limits: CodexLimits | null; runs: CodexRun[] }
172  fleet: FleetEntry[]
173}
174
175export type ContextView = {
176  model: string
177  total: number
178  max: number
179  categories: { name: string; tokens: number }[]
180  mcp: { server: string; tokens: number; tools: number }[]
181  resend: number | null
182  readUsd: number | null
183  writeUsd: number | null
184}
185
186export type LiveAgent = { label: string; usd: number; isRunning: boolean }
187
188/** The main thread's current (or last) turn, for the band above the prompt. */
189export type Live = {
190  /** estimated points of the 5h limit this turn used, by learned model weights */
191  pts?: number
192  turnId: string
193  startedAt: number
194  endedAt: number | null
195  usd: number
196  tok: Tokens
197  reqs: number
198  agents: LiveAgent[]
199}
200
201export type Tab = 'overview' | 'sessions' | 'codex' | 'threads' | 'tasks' | 'breakdown' | 'history' | 'accuracy' | 'context'
202export type Scope = 'session' | 'window' | 'week'
203
204declare module 'claude-code' {
205  interface PluginState {
206    'usage-meter': {
207      view: View | null
208      pulse: Pulse | null
209      mine: ScopeView | null
210      context: ContextView | null
211      cc: number | null
212      live: Live | null
213      tab: Tab
214      scope: Scope
215      expanded: string[]
216      isAllShown: boolean
217      isBandHidden: boolean
218    }
219  }
220}
221