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

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.
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.
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
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.~/.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.
Two meters, kept separate:
mod/prices.json, with dated changes), so the Sonnet 5.5 cache-read cut shows up here."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.
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).
MIT
hooks/register.tsx 1314 lines1import { 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 lines1/** 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