See whether you can go hard or should hold back: live 5-hour and 7-day quota pace, projection at reset, daily budget, quota size in API dollars, cost per…

A Claude Code pane that answers one question: can I go hard right now, or should I hold back? It tracks your subscription quota for the 5-hour and 7-day windows, projects where you will land at reset and tells you how much you can spend per day or per hour. It also shows what each project costs you, warns you before a limit and recaps last week.
7-day window · Ahead of pace: ease off 5-hour window · Room to spare
███████████████│█████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 44% ██████████████████████░░░░░│░░░░░░░░░░░░░░░░░░░░ 45%
Used 44% · resets Sun 11:00 (in 4d 20h) Used 45% · resets 17:10 (in 2h 10m)
Projected at reset 77% from your last 4 weeks Projected at reset 79% linear, no history yet
Budget 12%/day to land at 100% Budget 25%/hour to land at 100%
Today 14% of 14% ██████████ Quota size: learning (needs 5 points)
Sun 11 ██████████░░░░░░░░░░░░░░ 9%
Mon 12 ████████████████████████ 21%
Tue 13 ████████████████░░░░░░░░ 14% today
Quota ≈ $260 / week (1% ≈ $2.60)
This session $6.00 (2.3%)
Top projects this week
api $20 (7.7%)
burnrate $6.00 (2.3%)
Last week peaked 50%
│ marks where an even pace would be now. $ are API-equivalent and count this machine only.
More states and widths (60, 90 and 140 columns, band-* for the band) live in docs/snapshots.
In Claude Code:
/plugin install burnrate --marketplace axelhamil/burnrate
Answer y to add the axelhamil/burnrate marketplace, then pick a scope (user, project or local).
From a shell:
claude plugin install burnrate --marketplace axelhamil/burnrate
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with axelhamil/burnrate, then Add plugin.
/burnrate toggles the pane. It refreshes on every API response and once a minute.
A one-line band also shows right above the prompt, behind a bar tinted by the verdict, for example ▌ ● easy tiger 7d → 77% 12%/d today █████ 14/14% session 2.3% ▄█▆ 5h 45%. It opens with the worse verdict of the two windows in a word (go wild, cruising, easy tiger, hit the brakes, tapped out), then the 7d projection, the daily budget, today against its budget (with a bar from 82 columns), what this session cost in quota points (in dollars until the quota size is learned), one bar per day with a reading, shown from 2 days (yellow above the even pace) and the 5h use. When the line is too long for the terminal it drops the day bars first, then the session, today and the budget. Turn it off with the statusLine option (default on) in the plugin settings.
On Opus, when the worse verdict is easy tiger or above, the band ends with try sonnet.
Every figure in the band is clickable: a click toggles the pane, and a click on try sonnet puts /model sonnet in an empty prompt, ready to send.
The pane also lists this session's cost and the top 5 projects of the current 7d window (a project is the folder the session started in), with their share of the quota once its size is known, and a Last week line with the peak of the 7d window that closed in the last 7 days and its busiest day.
With the alerts option (default on):
easy tiger up (burnrate · 5h hit the brakes), shared across sessions;burnrate · last week peaked 62%, busiest Sat 10 at 40%), once per closed week.Each window gets a verdict:
| Verdict | Band | When |
|---|---|---|
| Limit reached | tapped out | used is 100% |
| Will hit the limit around ... | hit the brakes | projection above 100% |
| Ahead of pace: ease off | easy tiger | budget below 85% of the even pace |
| Room to spare | go wild | budget at least 115% of the even pace and projection at most 85% |
| On track | cruising | anything else |
Notation: p is the percentage used, never below the peak any session recorded for the window (an idle session's reading goes stale), e the elapsed fraction of the window (0 to 1). The last 6 closed windows of the same kind are weighted 0.5^(age / 2), age 0 being the most recent.
T, the typical peak, is the weighted mean of their peaks.R is the weighted mean of max(0, peak - use at that offset).Projection at reset, first that applies:
p + R (label from your last N weeks)p + (1 - e) * (p + (1 - e) * T) (label typical week)p / e, shown once at least 10% of the window has elapsedBudget, per day for 7d and per hour for 5h: (100 - p) / units left before reset. The even pace is 100 / 7 per day or 100 / 5 per hour; the budget tone compares the two.
Today (7d only): used today = p - p at day start, against min(headroom, headroom / days left from day start) where headroom = 100 - p at day start. The day start is the window's peak before the first reading of the day, or that reading when the window has none or was last sampled more than a day ago.
Hit time, when the projection passes 100%: rate = (projection - p) / seconds left, hit at = now + (100 - p) / rate.
Quota size: Claude Code reports a session cost in API-equivalent dollars. burnrate records it per hour and compares it with the hourly samples of each window, 7d and 5h alike. For a window, $ per 1% = sum of $ spent in hours where the percentage rose / points gained, once at least 5 points were gained with some spend recorded. Quota ≈ 100 * $ per 1% per week or per 5 hours. While the current window has under 5 points, the size falls back to the latest closed window that has a ratio. Windows that started before the 49-day cost retention are left out.
Quota change (7d only): the current and previous windows are compared with the median $ per 1% of the closed windows before them. When both differ by more than 20% in the same direction, the pane shows Quota up ~N% vs usual or Quota down ~N% vs usual.
$.store, key history): peaks per window, the percentage at each day start and hourly samples. Peaks and hourly samples are kept 49 days (enough for 6 past weeks), day starts 30 days.lastReadings: the last readings recorded, so an unchanged reading polled by the minute ticker is not recorded again.alerts (worst verdict reached and notification per window, until the window resets) and reportedWeek (the last week recapped).~/.claude/burnrate/cost/<sessionId>.json (or under $CLAUDE_CONFIG_DIR): one file per session with its hourly cost and project folder name, so concurrent sessions never write the same file. Buckets older than 49 days are dropped; files untouched for 49 days are ignored but stay on disk, since the plugin API cannot delete files.Nothing leaves your machine.
claude --plugin-dir .
claude plugin validate .
claude plugin test .
scripts/snapshots.sh regenerates docs/snapshots/*.txt and *.ansi from the test harness. tsc -p . type-checks once Claude Code has laid .claude-plugin/types/.
hooks/register.tsx 271 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { BurnrateCostLedger, BurnrateHistory, BurnrateReading, BurnrateSessionCost, BurnrateSnapshot } from '../types'
4import { alertsFor, parseAlertState, withNotified, type Notice } from './alerts'
5import { bandTree } from './band'
6import { insightsOf, type WindowInsight } from './insight'
7import { EMPTY_HISTORY, localDate, parseHistory, recordReadings, RETENTION_MS, withPeaks } from './pace'
8import { paneTree, type Elements } from './pane'
9import { mergeCosts, parseLedger, recordCost } from './quota-size'
10import { reportText } from './report'
11
12const PANE = 'burnrate'
13const STORE_KEY = 'history'
14const LAST_READINGS_KEY = 'lastReadings'
15const ALERTS_KEY = 'alerts'
16const REPORTED_WEEK_KEY = 'reportedWeek'
17const UNLABELED = 'other'
18const ALERT_TOAST_MS = 8_000
19const SONNET_COMMAND = '/model sonnet'
20const TICK_MS = 60_000
21const EMPTY_SNAPSHOT: BurnrateSnapshot = {
22 readings: [],
23 history: EMPTY_HISTORY,
24 costs: {},
25 sessions: [],
26 sessionId: '',
27 model: '',
28 now: 0,
29}
30
31type Settings = { isAlertsOn: boolean }
32
33const snapshot = { plugin: 'burnrate', key: 'snapshot' } as const
34
35let ticker: { cancel: () => void } | undefined
36let pending: Promise<void> = Promise.resolve()
37
38function toReadings(rateLimits: readonly BurnrateReading[]): BurnrateReading[] {
39 return rateLimits.map(({ kind, percentUsed, resetsAt }) =>
40 resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
41 )
42}
43
44async function costDir($: EngineInterface): Promise<string> {
45 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
46 const home = await $.env.get('HOME')
47 const base = configDir ?? `${home ?? ''}/.claude`
48
49 return `${base}/burnrate/cost`
50}
51
52async function readLedger($: EngineInterface, path: string): Promise<BurnrateCostLedger | null> {
53 if (!(await $.fs.exists(path))) return null
54
55 return parseLedger(await $.fs.read(path))
56}
57
58async function sessionLabel($: EngineInterface): Promise<string> {
59 const root = await $.session.root()
60
61 return root.split('/').filter(Boolean).at(-1) ?? UNLABELED
62}
63
64async function recordSessionCost($: EngineInterface, dir: string, sessionId: string, usd: number, now: number): Promise<void> {
65 const path = `${dir}/${sessionId}.json`
66 const ledger = await readLedger($, path)
67 const recorded = { ...recordCost(ledger, usd, now), label: ledger?.label ?? (await sessionLabel($)) }
68 if (ledger !== null && JSON.stringify(ledger) === JSON.stringify(recorded)) return
69
70 await $.fs.write(path, JSON.stringify(recorded))
71}
72
73async function loadSessions($: EngineInterface, dir: string, now: number): Promise<BurnrateSessionCost[]> {
74 if (!(await $.fs.exists(dir))) return []
75
76 const entries = await $.fs.list(dir)
77 const recent = entries.filter(
78 entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.mtimeMs >= now - RETENTION_MS,
79 )
80 const ledgers = await Promise.all(
81 recent.map(async entry => {
82 const ledger = await readLedger($, `${dir}/${entry.name}`)
83 if (ledger === null) return []
84
85 const id = entry.name.slice(0, -'.json'.length)
86
87 return [{ id, label: ledger.label ?? UNLABELED, hours: ledger.hours }]
88 }),
89 )
90
91 return ledgers.flat()
92}
93
94async function readState($: EngineInterface): Promise<BurnrateSnapshot> {
95 const { value } = await $.state.get(snapshot)
96
97 return { ...EMPTY_SNAPSHOT, ...value, history: parseHistory(value?.history) }
98}
99
100async function isNewReading($: EngineInterface, readings: readonly BurnrateReading[]): Promise<boolean> {
101 const last = await $.store.get(LAST_READINGS_KEY)
102
103 return JSON.stringify(last) !== JSON.stringify(readings)
104}
105
106async function recordedHistory(
107 $: EngineInterface,
108 stored: BurnrateHistory,
109 readings: readonly BurnrateReading[],
110 now: number,
111 isFresh: boolean,
112): Promise<BurnrateHistory> {
113 if (!isFresh && !(await isNewReading($, readings))) return stored
114
115 const history = recordReadings(stored, readings, now, localDate(now))
116 if (history !== stored) await $.store.set(STORE_KEY, history)
117 await $.store.set(LAST_READINGS_KEY, readings)
118
119 return history
120}
121
122async function sentKeys($: EngineInterface, notices: readonly Notice[]): Promise<string[]> {
123 const sent = await Promise.all(
124 notices.map(notice =>
125 $.ui.notify(notice.text, { title: 'burnrate' }).then(
126 () => [notice.key],
127 () => [],
128 ),
129 ),
130 )
131
132 return sent.flat()
133}
134
135async function raiseAlerts($: EngineInterface, insights: readonly WindowInsight[], now: number): Promise<void> {
136 const previous = parseAlertState(await $.store.get(ALERTS_KEY))
137 const { toasts, notices, state } = alertsFor(insights, previous, now)
138
139 for (const text of toasts) $.ui.toast(text, { timeoutMs: ALERT_TOAST_MS })
140
141 const notified = withNotified(state, await sentKeys($, notices))
142 if (JSON.stringify(notified) !== JSON.stringify(previous)) await $.store.set(ALERTS_KEY, notified)
143}
144
145async function reportLastWeek($: EngineInterface, insights: readonly WindowInsight[]): Promise<void> {
146 const report = insights.find(insight => insight.lastWeek !== null)?.lastWeek
147 if (report === undefined || report === null || (await $.store.get(REPORTED_WEEK_KEY)) === report.key) return
148
149 await $.store.set(REPORTED_WEEK_KEY, report.key)
150 $.ui.toast(reportText(report), { timeoutMs: ALERT_TOAST_MS })
151}
152
153async function refreshNow(
154 $: EngineInterface,
155 settings: Settings,
156 rateLimits: readonly BurnrateReading[],
157 usd: number | undefined,
158 isFresh: boolean,
159): Promise<void> {
160 const now = await $.clock.now()
161 const stored = parseHistory(await $.store.get(STORE_KEY))
162 const readings = withPeaks(toReadings(rateLimits), stored)
163 const history = await recordedHistory($, stored, readings, now, isFresh)
164
165 const dir = await costDir($)
166 const sessionId = await $.session.id()
167 if (usd !== undefined) await recordSessionCost($, dir, sessionId, usd, now)
168
169 const sessions = await loadSessions($, dir, now)
170 const costs = mergeCosts(sessions, now)
171 const model = await $.session.model()
172 const next: BurnrateSnapshot = { readings, history, costs, sessions, sessionId, model, now }
173 await $.state.set(snapshot, next)
174
175 if (!settings.isAlertsOn) return
176
177 const insights = insightsOf(next)
178 await raiseAlerts($, insights, now)
179 await reportLastWeek($, insights)
180}
181
182function refresh(
183 $: EngineInterface,
184 settings: Settings,
185 rateLimits: readonly BurnrateReading[],
186 usd: number | undefined,
187 isFresh: boolean,
188): Promise<void> {
189 const run = pending.then(() => refreshNow($, settings, rateLimits, usd, isFresh))
190 pending = run.catch(() => undefined)
191
192 return run
193}
194
195function ensureTicker($: EngineInterface, settings: Settings): void {
196 if (ticker !== undefined) return
197
198 ticker = $.clock.every(TICK_MS, () => {
199 void $.session
200 .usage()
201 .then(usage => refresh($, settings, usage.rateLimits, usage.cost?.usd, false))
202 .catch((error: unknown) => $.ui.log(`burnrate refresh failed: ${String(error)}`, { to: 'debug' }))
203 })
204}
205
206async function suggestSonnet($: EngineInterface): Promise<void> {
207 const draft = await $.prompt.read()
208 if (draft.text.trim() !== '') return $.ui.toast('Clear the prompt to switch to Sonnet')
209
210 await $.prompt.fill({ text: SONNET_COMMAND })
211}
212
213async function togglePane($: EngineInterface): Promise<void> {
214 const panes = await $.ui.panes()
215 const isOpen = panes.some(pane => pane.id === PANE)
216 if (isOpen) return $.ui.close({ id: PANE })
217
218 await $.ui.open({ id: PANE, title: 'burnrate' })
219}
220
221export const register: Register = (on, options) => {
222 const isBandOn = options.statusLine !== false
223 const settings: Settings = { isAlertsOn: options.alerts !== false }
224
225 on('session.start', async ($, e, next) => {
226 await $.command.register({ name: 'burnrate', description: 'Toggle the burnrate quota pane', immediate: true })
227 const usage = await $.session.usage()
228 await refresh($, settings, usage.rateLimits, usage.cost?.usd, false)
229 ensureTicker($, settings)
230
231 return next(e)
232 })
233
234 on('command.run', { command: 'burnrate' }, async $ => {
235 ensureTicker($, settings)
236 await togglePane($)
237
238 return {}
239 })
240
241 on('session.measure', async ($, e, next) => {
242 ensureTicker($, settings)
243 const isFresh = e.changed.includes('rateLimits')
244 if (isFresh || e.changed.includes('cost')) await refresh($, settings, e.rateLimits, e.cost?.usd, isFresh)
245
246 return next(e)
247 })
248
249 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250 const { Box, Text } = $.ui.resolve(e)
251 const elements: Elements = { Box, Text }
252 const state = await readState($)
253
254 return paneTree(elements, insightsOf(state), localDate(state.now), e.props.bodyColumns)
255 })
256
257 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
258 if (!isBandOn || e.props.hasSurvey) return next(e)
259
260 const { Box, Button, Text } = $.ui.resolve(e)
261 const state = await readState($)
262 const logged = (action: Promise<void>) => {
263 void action.catch((error: unknown) => $.ui.log(`burnrate action failed: ${String(error)}`, { to: 'debug' }))
264 }
265 const actions = { togglePane: () => logged(togglePane($)), suggestSonnet: () => logged(suggestSonnet($)) }
266 const tree = bandTree({ Box, Button, Text }, insightsOf(state), e.props.bodyColumns, state.model, actions)
267
268 return tree ?? next(e)
269 })
270}
271hooks/alerts.ts 75 lines1import type { BurnrateAlertState } from '../types'
2import { formatClock } from './format'
3import { isVerdict, VERDICT_SEVERITY, VERDICT_WORDS, type Verdict, type WindowInsight } from './insight'
4import { resetsAtOfKey } from './pace'
5
6export type Notice = { key: string; text: string }
7
8type Alerts = { toasts: string[]; notices: Notice[]; state: BurnrateAlertState }
9
10const IMMINENT_MS = 30 * 60_000
11const BASELINE: Verdict = 'on_track'
12const LABELS: Record<WindowInsight['view']['kind'], string> = { seven_day: '7d', five_hour: '5h' }
13
14function noticeFor({ view, verdict, hitAt }: WindowInsight, now: number): string | null {
15 const isWeek = view.kind === 'seven_day'
16 if (verdict === 'limit') return `${LABELS[view.kind]} limit reached, resets ${formatClock(view.resetsAt, isWeek)}`
17 if (hitAt === null || hitAt - now > IMMINENT_MS) return null
18
19 return `${LABELS[view.kind]} limit around ${formatClock(hitAt, isWeek)}, ease off now`
20}
21
22export function parseAlertState(value: unknown): BurnrateAlertState {
23 if (typeof value !== 'object' || value === null) return {}
24
25 const entries = Object.entries(value).flatMap(([key, entry]: [string, unknown]) => {
26 if (typeof entry !== 'object' || entry === null || !('verdict' in entry) || !('notified' in entry)) return []
27 if (!isVerdict(entry.verdict) || typeof entry.notified !== 'boolean') return []
28
29 return [[key, { verdict: entry.verdict, notified: entry.notified }] as const]
30 })
31
32 return Object.fromEntries(entries)
33}
34
35function isLive(key: string, now: number): boolean {
36 const resetsAt = resetsAtOfKey(key)
37
38 return resetsAt !== null && resetsAt > now
39}
40
41export function alertsFor(insights: readonly WindowInsight[], previous: BurnrateAlertState, now: number): Alerts {
42 const toasts: string[] = []
43 const notices: Notice[] = []
44 const state: BurnrateAlertState = Object.fromEntries(Object.entries(previous).filter(([key]) => isLive(key, now)))
45
46 for (const insight of insights) {
47 const { view, verdict } = insight
48 const before = previous[view.key]
49 const beforeVerdict = isVerdict(before?.verdict) ? before.verdict : BASELINE
50 const severity = VERDICT_SEVERITY[verdict]
51 if (severity > VERDICT_SEVERITY[BASELINE] && severity > VERDICT_SEVERITY[beforeVerdict]) {
52 toasts.push(`burnrate · ${LABELS[view.kind]} ${VERDICT_WORDS[verdict]}`)
53 }
54
55 const isNotified = before?.notified === true
56 const notice = isNotified ? null : noticeFor(insight, now)
57 if (notice !== null) notices.push({ key: view.key, text: notice })
58
59 const worst = VERDICT_SEVERITY[verdict] > VERDICT_SEVERITY[beforeVerdict] ? verdict : beforeVerdict
60 state[view.key] = { verdict: worst, notified: isNotified }
61 }
62
63 return { toasts, notices, state }
64}
65
66export function withNotified(state: BurnrateAlertState, keys: readonly string[]): BurnrateAlertState {
67 const notified = keys.flatMap(key => {
68 const entry = state[key]
69
70 return entry === undefined ? [] : [[key, { ...entry, notified: true }] as const]
71 })
72
73 return { ...state, ...Object.fromEntries(notified) }
74}
75hooks/band.tsx 207 lines1import type { ButtonProps, ElementConstructor, RenderElement } from 'claude-code'
2
3import { formatPct, formatUsd } from './format'
4import { VERDICT_SEVERITY, VERDICT_WORDS, worstVerdict, type Verdict, type WindowInsight } from './insight'
5import { EVEN_DAY_PCT, projectionTone, type DayUsage } from './pace'
6import { dayScale, smallBar, toneProps, VERDICT_COLORS, type Elements } from './pane'
7
8const SPARK_BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
9const WIDE_COLUMNS = 82
10const GAP_COLUMNS = 3
11const TODAY_BAR_WIDTH = 5
12const ADVICE_SEVERITY = VERDICT_SEVERITY.ahead
13const RANKS = { spark: 0, session: 1, today: 2, budget: 3, advice: 4, hours: 5, week: 6 } as const
14
15type Part = { key: string; text: string; node: RenderElement; rank: number }
16
17type BandElements = Elements & { Button: ElementConstructor<ButtonProps> }
18
19type BandActions = { togglePane: () => void; suggestSonnet: () => void }
20
21function sparkGlyph(used: number, top: number): string {
22 return SPARK_BARS[Math.min(SPARK_BARS.length - 1, Math.floor((used / top) * SPARK_BARS.length))] ?? ''
23}
24
25export function sparkGlyphs(days: readonly DayUsage[]): string[] {
26 const top = dayScale(days)
27
28 return days.map(day => sparkGlyph(day.used, top))
29}
30
31function sessionPart({ Text }: Elements, { spend, size }: WindowInsight): Part | null {
32 if (spend === null || spend.sessionUsd <= 0) return null
33
34 const value = size === null ? formatUsd(spend.sessionUsd) : formatPct(spend.sessionUsd / size.usdPerPoint)
35 const node = (
36 <Text>
37 <Text dimColor>session </Text>
38 {value}
39 </Text>
40 )
41
42 return { key: 'session', text: `session ${value}`, node, rank: RANKS.session }
43}
44
45function projectionPart({ Text }: Elements, { view }: WindowInsight): Part {
46 if (view.projected === null) {
47 const node = (
48 <Text>
49 <Text dimColor>7d </Text>
50 {formatPct(view.pct)}
51 </Text>
52 )
53
54 return { key: 'week', text: `7d ${formatPct(view.pct)}`, node, rank: RANKS.week }
55 }
56
57 const projected = Math.round(view.projected)
58 const node = (
59 <Text>
60 <Text dimColor>7d → </Text>
61 <Text {...toneProps(projectionTone(view.projected))}>{projected}%</Text>
62 </Text>
63 )
64
65 return { key: 'week', text: `7d → ${projected}%`, node, rank: RANKS.week }
66}
67
68function todayPart(elements: Elements, { view }: WindowInsight, columns: number): Part | null {
69 if (view.today === null) return null
70
71 const { Text } = elements
72 const { used, budget, tone } = view.today
73 const isWide = columns >= WIDE_COLUMNS
74 const figures = `${Math.round(used)}/${formatPct(budget)}`
75 const node = (
76 <Text>
77 <Text dimColor>today </Text>
78 {isWide && smallBar(elements, budget > 0 ? used / budget : 1, TODAY_BAR_WIDTH, tone)}
79 {isWide && ' '}
80 <Text {...toneProps(tone)}>{Math.round(used)}</Text>
81 <Text dimColor>/{formatPct(budget)}</Text>
82 </Text>
83 )
84 const bar = isWide ? `${'█'.repeat(TODAY_BAR_WIDTH)} ` : ''
85
86 return { key: 'today', text: `today ${bar}${figures}`, node, rank: RANKS.today }
87}
88
89function budgetPart({ Text }: Elements, { view }: WindowInsight): Part | null {
90 if (view.budget === null) return null
91
92 const value = formatPct(view.budget.value)
93 const node = (
94 <Text>
95 <Text {...toneProps(view.budget.tone)}>{value}</Text>
96 <Text dimColor>/d</Text>
97 </Text>
98 )
99
100 return { key: 'budget', text: `${value}/d`, node, rank: RANKS.budget }
101}
102
103function sparkPart({ Text }: Elements, { days }: WindowInsight): Part | null {
104 if (days.length < 2) return null
105
106 const glyphs = sparkGlyphs(days)
107 const node = (
108 <Text>
109 {days.map((day, index) => (
110 <Text color={day.used > EVEN_DAY_PCT ? 'warning' : 'success'}>{glyphs[index]}</Text>
111 ))}
112 </Text>
113 )
114
115 return { key: 'spark', text: glyphs.join(''), node, rank: RANKS.spark }
116}
117
118function weekParts(elements: Elements, insight: WindowInsight, columns: number): Part[] {
119 const optional = [
120 budgetPart(elements, insight),
121 todayPart(elements, insight, columns),
122 sessionPart(elements, insight),
123 sparkPart(elements, insight),
124 ]
125
126 return [projectionPart(elements, insight), ...optional.filter(part => part !== null)]
127}
128
129function headPart({ Text }: Elements, verdict: Verdict): Part {
130 const word = `● ${VERDICT_WORDS[verdict]}`
131 const node = (
132 <Text color={VERDICT_COLORS[verdict]}>
133 ▌ <Text bold>{word}</Text>
134 </Text>
135 )
136
137 return { key: 'verdict', text: `▌ ${word}`, node, rank: Number.POSITIVE_INFINITY }
138}
139
140function hoursPart({ Text }: Elements, { view, verdict }: WindowInsight): Part {
141 const pct = formatPct(view.pct)
142 const node = (
143 <Text>
144 <Text dimColor>5h </Text>
145 <Text color={VERDICT_COLORS[verdict]}>{pct}</Text>
146 </Text>
147 )
148
149 return { key: 'hours', text: `5h ${pct}`, node, rank: RANKS.hours }
150}
151
152function advicePart({ Text }: Elements, verdict: Verdict, model: string): Part | null {
153 const isAdvised = VERDICT_SEVERITY[verdict] >= ADVICE_SEVERITY && model.toLowerCase().includes('opus')
154 if (!isAdvised) return null
155
156 return { key: 'advice', text: 'try sonnet', node: <Text color="warning">try sonnet</Text>, rank: RANKS.advice }
157}
158
159function lineWidth(parts: readonly Part[]): number {
160 return parts.reduce((sum, part) => sum + [...part.text].length + GAP_COLUMNS, -GAP_COLUMNS)
161}
162
163function fitting(parts: readonly Part[], columns: number): Part[] {
164 if (lineWidth(parts) <= columns) return [...parts]
165
166 const droppable = parts.filter(part => Number.isFinite(part.rank))
167 if (droppable.length === 0) return [...parts]
168
169 const lowest = Math.min(...droppable.map(part => part.rank))
170
171 return fitting(
172 parts.filter(part => part.rank !== lowest),
173 columns,
174 )
175}
176
177export function bandTree(
178 elements: BandElements,
179 insights: readonly WindowInsight[],
180 columns: number,
181 model: string,
182 actions: BandActions,
183): RenderElement | null {
184 const verdict = worstVerdict(insights)
185 if (verdict === null) return null
186
187 const { Box, Button } = elements
188 const week = insights.find(insight => insight.view.kind === 'seven_day')
189 const hours = insights.find(insight => insight.view.kind === 'five_hour')
190 const optional = [hours === undefined ? null : hoursPart(elements, hours), advicePart(elements, verdict, model)]
191 const parts = [
192 headPart(elements, verdict),
193 ...(week === undefined ? [] : weekParts(elements, week, columns)),
194 ...optional.filter(part => part !== null),
195 ]
196
197 return (
198 <Box flexDirection="row" columnGap={GAP_COLUMNS}>
199 {fitting(parts, columns).map(part => (
200 <Button plain key={part.key} onPress={part.key === 'advice' ? actions.suggestSonnet : actions.togglePane}>
201 {part.node}
202 </Button>
203 ))}
204 </Box>
205 )
206}
207hooks/insight.ts 101 lines1import type { BurnrateSnapshot } from '../types'
2import { dailyUsage, describeWindow, localDate, WEEK_MS, type DayUsage, type WindowKind, type WindowView } from './pace'
3import { detectQuotaChange, quotaUsdPerPoint, windowRatios, type QuotaChange } from './quota-size'
4import { lastWeekReport, type WeekReport } from './report'
5import { weekSpend, type WeekSpend } from './sessions'
6
7export type Verdict = 'room' | 'on_track' | 'ahead' | 'will_hit' | 'limit'
8
9type QuotaSize = { usdPerPoint: number; change: QuotaChange | null }
10
11export type WindowInsight = {
12 view: WindowView
13 verdict: Verdict
14 hitAt: number | null
15 size: QuotaSize | null
16 days: DayUsage[]
17 spend: WeekSpend | null
18 lastWeek: WeekReport | null
19}
20
21export const VERDICT_WORDS: Record<Verdict, string> = {
22 room: 'go wild',
23 on_track: 'cruising',
24 ahead: 'easy tiger',
25 will_hit: 'hit the brakes',
26 limit: 'tapped out',
27}
28export const VERDICT_SEVERITY: Record<Verdict, number> = { room: 0, on_track: 1, ahead: 2, will_hit: 3, limit: 4 }
29
30const AHEAD_RATIO = 0.85
31const ROOM_RATIO = 1.15
32const ROOM_MAX_PROJECTION = 85
33const KIND_ORDER: Record<WindowKind, number> = { seven_day: 0, five_hour: 1 }
34
35export function isVerdict(value: unknown): value is Verdict {
36 return typeof value === 'string' && value in VERDICT_SEVERITY
37}
38
39export function worstVerdict(insights: readonly WindowInsight[]): Verdict | null {
40 return insights.reduce<Verdict | null>(
41 (worst, { verdict }) => (worst === null || VERDICT_SEVERITY[verdict] > VERDICT_SEVERITY[worst] ? verdict : worst),
42 null,
43 )
44}
45
46export function verdictOf(view: WindowView): Verdict {
47 if (view.pct >= 100) return 'limit'
48 if (view.projected !== null && view.projected > 100) return 'will_hit'
49
50 const ratio = view.budget?.ratio
51 if (ratio === undefined) return 'on_track'
52 if (ratio < AHEAD_RATIO) return 'ahead'
53 if (ratio >= ROOM_RATIO && (view.projected ?? 0) <= ROOM_MAX_PROJECTION) return 'room'
54
55 return 'on_track'
56}
57
58function hitTime(view: WindowView, now: number): number | null {
59 if (view.projected === null || view.projected <= 100 || view.secondsLeft <= 0) return null
60
61 const pctPerSecond = (view.projected - view.pct) / view.secondsLeft
62 if (pctPerSecond <= 0) return null
63
64 return now + ((100 - view.pct) / pctPerSecond) * 1_000
65}
66
67function quotaSize(state: BurnrateSnapshot, view: WindowView): QuotaSize | null {
68 const ratios = windowRatios(state.history.hours, state.costs, view.kind, state.now)
69 const usdPerPoint = quotaUsdPerPoint(ratios, view.key)
70 if (usdPerPoint === null) return null
71
72 const change = view.kind === 'seven_day' ? detectQuotaChange(ratios, view.key) : null
73
74 return { usdPerPoint, change }
75}
76
77export function insightsOf(state: BurnrateSnapshot): WindowInsight[] {
78 const today = localDate(state.now)
79 const views = state.readings.flatMap(reading => {
80 const view = describeWindow(reading, state.history, state.now, today)
81
82 return view === null ? [] : [view]
83 })
84
85 return views
86 .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind])
87 .map(view => {
88 const isWeek = view.kind === 'seven_day'
89
90 return {
91 view,
92 verdict: verdictOf(view),
93 hitAt: hitTime(view, state.now),
94 size: quotaSize(state, view),
95 days: isWeek ? dailyUsage(state.history, view.key, view.pct) : [],
96 spend: isWeek ? weekSpend(state.sessions, view.resetsAt - WEEK_MS, state.sessionId) : null,
97 lastWeek: isWeek ? lastWeekReport(state.history, view.resetsAt, state.now) : null,
98 }
99 })
100}
101hooks/pace.ts 401 lines1import type { BurnrateDayStart, BurnrateHistory, BurnrateHourSample, BurnratePeak, BurnrateReading } from '../types'
2import { outlookOf, type Outlook, type PastWindow, type TracePoint } from './outlook'
3
4export type WindowKind = 'five_hour' | 'seven_day'
5
6export type Tone = 'normal' | 'dim' | 'warn' | 'alert'
7
8type Budget = { value: number; unit: 'd' | 'h'; ratio: number; tone: Tone }
9
10type Today = { used: number; budget: number; tone: Tone }
11
12export type DayUsage = { date: string; used: number }
13
14type ProjectionBasis = 'analog' | 'typical' | 'linear'
15
16export type WindowView = {
17 kind: WindowKind
18 key: string
19 pct: number
20 resetsAt: number
21 secondsLeft: number
22 elapsedPct: number
23 basis: ProjectionBasis
24 analogSample: number
25 projected: number | null
26 projectionTone: Tone
27 budget: Budget | null
28 today: Today | null
29}
30
31const MINUTE_MS = 60_000
32const HOUR_SECONDS = 3_600
33const DAY_SECONDS = 86_400
34const KEY_ROUNDING_MS = 10 * MINUTE_MS
35const ANALOG_WINDOWS = 6
36const MIN_LINEAR_ELAPSED = 0.1
37const ELAPSED_EPSILON = 1e-9
38
39export const HOUR_MS = HOUR_SECONDS * 1_000
40export const DAY_MS = DAY_SECONDS * 1_000
41export const WEEK_MS = 7 * DAY_MS
42export const RETENTION_MS = 49 * DAY_MS
43export const EVEN_DAY_PCT = 100 / 7
44
45const DAY_START_RETENTION_MS = 30 * DAY_MS
46
47export const WINDOW_SECONDS: Record<WindowKind, number> = {
48 five_hour: 5 * HOUR_SECONDS,
49 seven_day: 7 * DAY_SECONDS,
50}
51
52export const EMPTY_HISTORY: BurnrateHistory = { peaks: {}, dayStarts: {}, hours: {} }
53
54export function isWindowKind(kind: string): kind is WindowKind {
55 return kind === 'five_hour' || kind === 'seven_day'
56}
57
58function roundResetsAt(resetsAtMs: number): number {
59 return Math.round(resetsAtMs / KEY_ROUNDING_MS) * KEY_ROUNDING_MS
60}
61
62export function windowKey(kind: WindowKind, resetsAtMs: number): string {
63 return `${kind}@${roundResetsAt(resetsAtMs)}`
64}
65
66export function resetsAtOfKey(key: string): number | null {
67 const resetsAt = Number(key.split('@')[1])
68
69 return Number.isFinite(resetsAt) ? resetsAt : null
70}
71
72export function localDate(ms: number): string {
73 const date = new Date(ms)
74 const month = String(date.getMonth() + 1).padStart(2, '0')
75 const day = String(date.getDate()).padStart(2, '0')
76
77 return `${date.getFullYear()}-${month}-${day}`
78}
79
80function resetsAtMs(reading: BurnrateReading): number | null {
81 if (reading.resetsAt === undefined) return null
82
83 const parsed = Date.parse(reading.resetsAt)
84
85 return Number.isFinite(parsed) ? parsed : null
86}
87
88function withPeak(history: BurnrateHistory, kind: WindowKind, resetsAt: number, pct: number): BurnrateHistory {
89 const key = windowKey(kind, resetsAt)
90 const previous = history.peaks[key]
91 if (previous !== undefined && previous.peak >= pct) return history
92
93 const peak: BurnratePeak = { kind, resetsAt: roundResetsAt(resetsAt), peak: pct }
94
95 return { ...history, peaks: { ...history.peaks, [key]: peak } }
96}
97
98function withDayStart(history: BurnrateHistory, key: string, pct: number, now: number, today: string): BurnrateHistory {
99 const dayKey = `${key}#${today}`
100 if (history.dayStarts[dayKey] !== undefined) return history
101
102 const start: BurnrateDayStart = { windowKey: key, date: today, pct, seenAt: now }
103
104 return { ...history, dayStarts: { ...history.dayStarts, [dayKey]: start } }
105}
106
107function withHourSample(history: BurnrateHistory, key: string, pct: number, now: number): BurnrateHistory {
108 const hour = Math.floor(now / HOUR_MS)
109 const hourKey = `${key}#${hour}`
110 const previous = history.hours[hourKey]
111 if (previous !== undefined && previous.pct >= pct) return history
112
113 const sample: BurnrateHourSample = { windowKey: key, hour, pct }
114
115 return { ...history, hours: { ...history.hours, [hourKey]: sample } }
116}
117
118function kept<T>(entries: Record<string, T>, isKept: (entry: T) => boolean): Record<string, T> {
119 const all = Object.entries(entries)
120 const filtered = all.filter(([, entry]) => isKept(entry))
121 if (filtered.length === all.length) return entries
122
123 return Object.fromEntries(filtered)
124}
125
126function pruned(history: BurnrateHistory, now: number): BurnrateHistory {
127 const oldestPeak = now - RETENTION_MS
128 const oldestHour = Math.floor((now - RETENTION_MS) / HOUR_MS)
129 const peaks = kept(history.peaks, peak => peak.resetsAt >= oldestPeak)
130 const dayStarts = kept(history.dayStarts, start => start.seenAt >= now - DAY_START_RETENTION_MS)
131 const hours = kept(history.hours, sample => sample.hour >= oldestHour)
132 if (peaks === history.peaks && dayStarts === history.dayStarts && hours === history.hours) return history
133
134 return { peaks, dayStarts, hours }
135}
136
137function lastDayPeak(history: BurnrateHistory, key: string, now: number): number | undefined {
138 const oldestHour = Math.floor((now - DAY_MS) / HOUR_MS)
139 const isRecent = Object.values(history.hours).some(sample => sample.windowKey === key && sample.hour >= oldestHour)
140
141 return isRecent ? history.peaks[key]?.peak : undefined
142}
143
144export function recordReadings(
145 history: BurnrateHistory,
146 readings: readonly BurnrateReading[],
147 now: number,
148 today: string,
149): BurnrateHistory {
150 const recorded = readings.reduce((current, reading) => {
151 const resetsAt = resetsAtMs(reading)
152 if (resetsAt === null || !isWindowKind(reading.kind)) return current
153
154 const key = windowKey(reading.kind, resetsAt)
155 const previousPeak = lastDayPeak(current, key, now)
156 const peaked = withPeak(current, reading.kind, resetsAt, reading.percentUsed)
157 const sampled = withHourSample(peaked, key, reading.percentUsed, now)
158 if (reading.kind !== 'seven_day') return sampled
159
160 return withDayStart(sampled, key, previousPeak ?? reading.percentUsed, now, today)
161 }, history)
162
163 return pruned(recorded, now)
164}
165
166export function withPeaks(readings: readonly BurnrateReading[], history: BurnrateHistory): BurnrateReading[] {
167 return readings.map(reading => {
168 const resetsAt = resetsAtMs(reading)
169 if (resetsAt === null || !isWindowKind(reading.kind)) return reading
170
171 const peak = history.peaks[windowKey(reading.kind, resetsAt)]?.peak ?? reading.percentUsed
172
173 return { ...reading, percentUsed: Math.max(reading.percentUsed, peak) }
174 })
175}
176
177export function closedPeaks(history: BurnrateHistory, kind: WindowKind, resetsAt: number, now: number): BurnratePeak[] {
178 const current = roundResetsAt(resetsAt)
179
180 return Object.values(history.peaks)
181 .filter(peak => peak.kind === kind && peak.resetsAt < current && peak.resetsAt <= now)
182 .sort((a, b) => b.resetsAt - a.resetsAt)
183 .slice(0, ANALOG_WINDOWS)
184}
185
186function tracePoints(history: BurnrateHistory, peak: BurnratePeak, kind: WindowKind): TracePoint[] {
187 const key = windowKey(kind, peak.resetsAt)
188 const windowSeconds = WINDOW_SECONDS[kind]
189 const startMs = peak.resetsAt - windowSeconds * 1_000
190
191 return Object.values(history.hours)
192 .filter(sample => sample.windowKey === key)
193 .map(sample => {
194 const elapsedSeconds = ((sample.hour + 1) * HOUR_MS - startMs) / 1_000
195
196 return { elapsedSeconds: Math.min(windowSeconds, Math.max(0, elapsedSeconds)), pct: sample.pct }
197 })
198 .sort((a, b) => a.elapsedSeconds - b.elapsedSeconds)
199}
200
201function pastWindowOf(history: BurnrateHistory, peak: BurnratePeak, kind: WindowKind, elapsedSeconds: number): PastWindow {
202 const points = tracePoints(history, peak, kind)
203 const before = points.filter(point => point.elapsedSeconds <= elapsedSeconds).at(-1) ?? null
204 const after = points.find(point => point.elapsedSeconds > elapsedSeconds) ?? null
205
206 return { peak: peak.peak, before, after }
207}
208
209export function outlookFor(
210 history: BurnrateHistory,
211 kind: WindowKind,
212 resetsAt: number,
213 now: number,
214 elapsedSeconds: number,
215): Outlook | null {
216 const pastWindows = closedPeaks(history, kind, resetsAt, now).map(peak => pastWindowOf(history, peak, kind, elapsedSeconds))
217
218 return outlookOf(pastWindows, elapsedSeconds, WINDOW_SECONDS[kind])
219}
220
221type ProjectionInput = {
222 pct: number
223 secondsLeft: number
224 windowSeconds: number
225 outlook: Outlook | null
226}
227
228export function projectAtReset({ pct, secondsLeft, windowSeconds, outlook }: ProjectionInput): number | null {
229 const elapsed = Math.min(1, Math.max(0, 1 - secondsLeft / windowSeconds))
230
231 if (outlook === null) {
232 if (elapsed < MIN_LINEAR_ELAPSED - ELAPSED_EPSILON) return null
233
234 return pct / elapsed
235 }
236
237 if (outlook.remaining !== null) return pct + outlook.remaining
238
239 const remaining = 1 - elapsed
240 const blended = pct + remaining * outlook.typical
241
242 return pct + remaining * blended
243}
244
245function basisOf(outlook: Outlook | null): ProjectionBasis {
246 if (outlook === null) return 'linear'
247
248 return outlook.remaining === null ? 'typical' : 'analog'
249}
250
251export function projectionTone(projected: number | null): Tone {
252 if (projected === null) return 'dim'
253 if (projected > 100) return 'alert'
254 if (projected > 85) return 'warn'
255
256 return 'normal'
257}
258
259function paceTone(ratio: number): Tone {
260 if (ratio < 0.5) return 'alert'
261 if (ratio < 0.85) return 'warn'
262
263 return 'dim'
264}
265
266export function budgetLeft(kind: WindowKind, pct: number, secondsLeft: number): Budget | null {
267 if (secondsLeft <= 0) return null
268
269 const unitSeconds = kind === 'seven_day' ? DAY_SECONDS : HOUR_SECONDS
270 const evenSplit = (100 * unitSeconds) / WINDOW_SECONDS[kind]
271 const value = Math.max(0, 100 - pct) / (secondsLeft / unitSeconds)
272 const unit = kind === 'seven_day' ? 'd' : 'h'
273 const ratio = value / evenSplit
274
275 return { value, unit, ratio, tone: paceTone(ratio) }
276}
277
278export function todayUsage(pct: number, start: BurnrateDayStart, resetsAt: number): Today | null {
279 const daysLeft = (resetsAt - start.seenAt) / DAY_MS
280 if (daysLeft <= 0) return null
281
282 const used = Math.max(0, pct - start.pct)
283 const headroom = Math.max(0, 100 - start.pct)
284 const budget = Math.min(headroom, headroom / daysLeft)
285
286 return { used, budget, tone: todayTone(used, budget) }
287}
288
289function todayTone(used: number, budget: number): Tone {
290 if (used > budget) return 'alert'
291 if (used > 0.85 * budget) return 'warn'
292
293 return 'dim'
294}
295
296function startsOf(history: BurnrateHistory, key: string): BurnrateDayStart[] {
297 return Object.values(history.dayStarts)
298 .filter(start => start.windowKey === key)
299 .sort((a, b) => a.seenAt - b.seenAt)
300}
301
302export function dailyUsage(history: BurnrateHistory, key: string, pct: number): DayUsage[] {
303 const starts = startsOf(history, key)
304
305 return starts.map((start, index) => {
306 const end = starts[index + 1]?.pct ?? pct
307
308 return { date: start.date, used: Math.max(0, end - start.pct) }
309 })
310}
311
312export function describeWindow(
313 reading: BurnrateReading,
314 history: BurnrateHistory,
315 now: number,
316 today: string,
317): WindowView | null {
318 const resetsAt = resetsAtMs(reading)
319 if (resetsAt === null || !isWindowKind(reading.kind)) return null
320
321 if (resetsAt <= now) return null
322
323 const kind = reading.kind
324 const pct = reading.percentUsed
325 const key = windowKey(kind, resetsAt)
326 const secondsLeft = Math.max(0, (resetsAt - now) / 1_000)
327 const windowSeconds = WINDOW_SECONDS[kind]
328 const outlook = outlookFor(history, kind, resetsAt, now, Math.max(0, windowSeconds - secondsLeft))
329 const projected = projectAtReset({ pct, secondsLeft, windowSeconds, outlook })
330 const start = history.dayStarts[`${key}#${today}`]
331 const todayView = kind === 'seven_day' && start !== undefined ? todayUsage(pct, start, resetsAt) : null
332
333 return {
334 kind,
335 key,
336 pct,
337 resetsAt,
338 secondsLeft,
339 elapsedPct: Math.min(100, Math.max(0, 100 * (1 - secondsLeft / WINDOW_SECONDS[kind]))),
340 basis: basisOf(outlook),
341 analogSample: outlook?.traced ?? 0,
342 projected,
343 projectionTone: projectionTone(projected),
344 budget: budgetLeft(kind, pct, secondsLeft),
345 today: todayView,
346 }
347}
348
349function isRecord(value: unknown): value is Record<string, unknown> {
350 return typeof value === 'object' && value !== null && !Array.isArray(value)
351}
352
353function asPeak(value: unknown): BurnratePeak | null {
354 if (!isRecord(value)) return null
355
356 const { kind, resetsAt, peak } = value
357 if (typeof kind !== 'string' || typeof resetsAt !== 'number' || typeof peak !== 'number') return null
358
359 return { kind, resetsAt, peak }
360}
361
362function asDayStart(value: unknown): BurnrateDayStart | null {
363 if (!isRecord(value)) return null
364
365 const { windowKey: key, date, pct, seenAt } = value
366 if (typeof key !== 'string' || typeof date !== 'string' || typeof pct !== 'number' || typeof seenAt !== 'number') return null
367
368 return { windowKey: key, date, pct, seenAt }
369}
370
371function asHourSample(value: unknown): BurnrateHourSample | null {
372 if (!isRecord(value)) return null
373
374 const { windowKey: key, hour, pct } = value
375 if (typeof key !== 'string' || typeof hour !== 'number' || typeof pct !== 'number') return null
376
377 return { windowKey: key, hour, pct }
378}
379
380function validEntries<T>(value: unknown, parse: (entry: unknown) => T | null): Record<string, T> {
381 if (!isRecord(value)) return {}
382
383 const entries = Object.entries(value).flatMap(([key, entry]) => {
384 const parsed = parse(entry)
385
386 return parsed === null ? [] : [[key, parsed] as const]
387 })
388
389 return Object.fromEntries(entries)
390}
391
392export function parseHistory(value: unknown): BurnrateHistory {
393 if (!isRecord(value)) return EMPTY_HISTORY
394
395 return {
396 peaks: validEntries(value.peaks, asPeak),
397 dayStarts: validEntries(value.dayStarts, asDayStart),
398 hours: validEntries(value.hours, asHourSample),
399 }
400}
401hooks/pane.tsx 306 lines1import type { BoxProps, Color, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3import { formatClock, formatDay, formatDuration, formatPct, formatUsd } from './format'
4import type { Verdict, WindowInsight } from './insight'
5import { EVEN_DAY_PCT, projectionTone, type DayUsage, type Tone, type WindowView } from './pace'
6
7export type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
8
9type GaugeCell = { text: string; tone: CellTone }
10
11type CellTone = 'filled' | 'empty' | 'marker'
12
13type Layout = { gaugeWidth: number; barWidth: number; isWide: boolean; isSideBySide: boolean }
14
15const WIDE_COLUMNS = 80
16const SIDE_BY_SIDE_COLUMNS = 120
17const GAUGE_MAX = 48
18const BAR_MAX = 24
19const TITLES: Record<WindowView['kind'], string> = { seven_day: '7-day window', five_hour: '5-hour window' }
20const UNITS: Record<WindowView['kind'], string> = { seven_day: 'day', five_hour: 'hour' }
21const PERIODS_LONG: Record<WindowView['kind'], string> = { seven_day: 'week', five_hour: 'window' }
22const PERIODS: Record<WindowView['kind'], string> = { seven_day: 'week', five_hour: '5h' }
23export const VERDICT_COLORS: Record<Verdict, Color> = {
24 room: 'success',
25 on_track: 'text',
26 ahead: 'warning',
27 will_hit: 'error',
28 limit: 'error',
29}
30
31function layoutFor(columns: number): Layout {
32 return {
33 gaugeWidth: Math.max(10, Math.min(GAUGE_MAX, columns - 8)),
34 barWidth: Math.max(4, Math.min(BAR_MAX, columns - 22)),
35 isWide: columns >= WIDE_COLUMNS,
36 isSideBySide: columns >= SIDE_BY_SIDE_COLUMNS,
37 }
38}
39
40export function toneProps(tone: Tone): TextProps {
41 if (tone === 'alert') return { color: 'error' }
42 if (tone === 'warn') return { color: 'warning' }
43 if (tone === 'dim') return { dimColor: true }
44
45 return {}
46}
47
48function gaugeCells(width: number, usedPct: number, markerPct: number): GaugeCell[] {
49 const filled = Math.round((Math.min(100, Math.max(0, usedPct)) / 100) * width)
50 const marker = Math.min(width - 1, Math.round((markerPct / 100) * width))
51 const cells = Array.from({ length: width }, (_, index): CellTone => {
52 if (index === marker) return 'marker'
53
54 return index < filled ? 'filled' : 'empty'
55 })
56
57 return cells.reduce<GaugeCell[]>((runs, tone) => {
58 const glyph = tone === 'filled' ? '█' : tone === 'marker' ? '│' : '░'
59 const last = runs.at(-1)
60 if (last?.tone === tone) return [...runs.slice(0, -1), { text: last.text + glyph, tone }]
61
62 return [...runs, { text: glyph, tone }]
63 }, [])
64}
65
66function verdictText({ view, verdict, hitAt }: WindowInsight): string {
67 if (verdict === 'limit') return 'Limit reached'
68 if (verdict === 'will_hit' && hitAt !== null) return `Will hit the limit around ${formatClock(hitAt, view.kind === 'seven_day')}`
69 if (verdict === 'will_hit') return 'Will hit the limit before reset'
70 if (verdict === 'ahead') return 'Ahead of pace: ease off'
71 if (verdict === 'room') return 'Room to spare'
72
73 return 'On track'
74}
75
76function gauge({ Box, Text }: Elements, insight: WindowInsight, width: number): RenderElement {
77 const color = VERDICT_COLORS[insight.verdict]
78 const runs = gaugeCells(width, insight.view.pct, insight.view.elapsedPct)
79
80 return (
81 <Box flexDirection="row">
82 {runs.map(run => {
83 if (run.tone === 'filled') return <Text color={color}>{run.text}</Text>
84 if (run.tone === 'marker') return <Text bold>{run.text}</Text>
85
86 return <Text dimColor>{run.text}</Text>
87 })}
88 <Text> {formatPct(insight.view.pct)}</Text>
89 </Box>
90 )
91}
92
93function usedLine({ Text }: Elements, view: WindowView): RenderElement {
94 const clock = formatClock(view.resetsAt, view.kind === 'seven_day')
95
96 return (
97 <Text>
98 Used {formatPct(view.pct)} <Text dimColor>· resets {clock} (in {formatDuration(view.secondsLeft)})</Text>
99 </Text>
100 )
101}
102
103function basisText(view: WindowView): string {
104 if (view.basis === 'linear') return 'linear, no history yet'
105 if (view.basis === 'typical') return `typical ${PERIODS_LONG[view.kind]}`
106
107 const plural = view.analogSample === 1 ? '' : 's'
108
109 return `from your last ${view.analogSample} ${PERIODS_LONG[view.kind]}${plural}`
110}
111
112function projectionLine({ Text }: Elements, { view, verdict }: WindowInsight, layout: Layout): RenderElement | null {
113 if (verdict === 'limit') return null
114 if (view.projected === null) return <Text dimColor>Projected at reset: too early to tell</Text>
115
116 return (
117 <Text>
118 Projected at reset <Text {...toneProps(projectionTone(view.projected))}>{Math.round(view.projected)}%</Text>
119 {layout.isWide && <Text dimColor> {basisText(view)}</Text>}
120 </Text>
121 )
122}
123
124function budgetLine({ Text }: Elements, { view, verdict }: WindowInsight, layout: Layout): RenderElement | null {
125 if (verdict === 'limit' || view.budget === null) return null
126
127 const value = `${formatPct(view.budget.value)}/${UNITS[view.kind]}`
128
129 return (
130 <Text>
131 Budget <Text {...toneProps(view.budget.tone)}>{value}</Text>
132 {layout.isWide && <Text dimColor> to land at 100%</Text>}
133 </Text>
134 )
135}
136
137export function dayScale(days: readonly DayUsage[]): number {
138 return Math.max(EVEN_DAY_PCT, ...days.map(day => day.used))
139}
140
141export function smallBar({ Text }: Elements, ratio: number, width: number, tone: Tone): RenderElement {
142 const filled = Math.min(width, Math.round(Math.max(0, ratio) * width))
143
144 return (
145 <Text>
146 <Text {...toneProps(tone === 'dim' ? 'normal' : tone)}>{'█'.repeat(filled)}</Text>
147 <Text dimColor>{'░'.repeat(width - filled)}</Text>
148 </Text>
149 )
150}
151
152function todayLine(elements: Elements, view: WindowView, layout: Layout): RenderElement | null {
153 if (view.today === null) return null
154
155 const { Box, Text } = elements
156 const { used, budget, tone } = view.today
157 const ratio = budget > 0 ? used / budget : 1
158
159 return (
160 <Box flexDirection="row" columnGap={1}>
161 <Text>
162 Today <Text {...toneProps(tone)}>{formatPct(used)}</Text> of {formatPct(budget)}
163 </Text>
164 {layout.isWide && smallBar(elements, ratio, 10, tone)}
165 </Box>
166 )
167}
168
169function dayRow(elements: Elements, day: DayUsage, scale: number, layout: Layout, isToday: boolean): RenderElement {
170 const { Box, Text } = elements
171 const tone: Tone = day.used > EVEN_DAY_PCT ? 'warn' : 'normal'
172
173 return (
174 <Box key={day.date} flexDirection="row" columnGap={1}>
175 <Text bold={isToday} dimColor={!isToday}>
176 {formatDay(day.date)}
177 </Text>
178 {smallBar(elements, day.used / scale, layout.barWidth, tone)}
179 <Text bold={isToday} dimColor={!isToday}>
180 {formatPct(day.used)}
181 {isToday ? ' today' : ''}
182 </Text>
183 </Box>
184 )
185}
186
187function daysBlock(elements: Elements, days: DayUsage[], today: string, layout: Layout): RenderElement | null {
188 if (days.length === 0) return null
189
190 const scale = dayScale(days)
191 const { Box } = elements
192
193 return <Box flexDirection="column">{days.map(day => dayRow(elements, day, scale, layout, day.date === today))}</Box>
194}
195
196function quotaLines({ Text }: Elements, { view, size }: WindowInsight): RenderElement[] {
197 if (size === null) return [<Text dimColor>Quota size: learning (needs 5 points)</Text>]
198
199 const quota = (
200 <Text>
201 Quota ≈ {formatUsd(size.usdPerPoint * 100)} / {PERIODS[view.kind]}{' '}
202 <Text dimColor>(1% ≈ {formatUsd(size.usdPerPoint)})</Text>
203 </Text>
204 )
205 if (size.change === null) return [quota]
206
207 const change = (
208 <Text color={size.change.direction === 'down' ? 'error' : 'success'}>
209 Quota {size.change.direction} ~{size.change.percent}% vs usual
210 </Text>
211 )
212
213 return [quota, change]
214}
215
216function usdAndPct(usd: number, size: WindowInsight['size']): string {
217 if (size === null) return formatUsd(usd)
218
219 return `${formatUsd(usd)} (${formatPct(usd / size.usdPerPoint)})`
220}
221
222function spendLines({ Box, Text }: Elements, { spend, size }: WindowInsight): RenderElement[] {
223 if (spend === null || spend.projects.length === 0) return []
224
225 const session = <Text>This session {usdAndPct(spend.sessionUsd, size)}</Text>
226 const projects = spend.projects.map(project => (
227 <Box key={project.label} flexDirection="row" columnGap={1}>
228 <Text dimColor>{project.label}</Text>
229 <Text>{usdAndPct(project.usd, size)}</Text>
230 </Box>
231 ))
232
233 return [session, <Text dimColor>Top projects this week</Text>, ...projects]
234}
235
236function lastWeekLine({ Text }: Elements, { lastWeek }: WindowInsight): RenderElement | null {
237 if (lastWeek === null) return null
238
239 return (
240 <Text>
241 Last week peaked <Text {...toneProps(projectionTone(lastWeek.peak))}>{formatPct(lastWeek.peak)}</Text>
242 {lastWeek.busiest !== null && (
243 <Text dimColor>
244 {' '}
245 · busiest {formatDay(lastWeek.busiest.date)} at {formatPct(lastWeek.busiest.used)}
246 </Text>
247 )}
248 </Text>
249 )
250}
251
252function section(elements: Elements, insight: WindowInsight, today: string, layout: Layout): RenderElement {
253 const { Box, Text } = elements
254 const { view } = insight
255
256 return (
257 <Box key={view.kind} flexDirection="column" marginBottom={1}>
258 <Text>
259 <Text bold>{TITLES[view.kind]}</Text>
260 <Text dimColor> · </Text>
261 <Text bold color={VERDICT_COLORS[insight.verdict]}>
262 {verdictText(insight)}
263 </Text>
264 </Text>
265 {gauge(elements, insight, layout.gaugeWidth)}
266 {usedLine(elements, view)}
267 {projectionLine(elements, insight, layout)}
268 {budgetLine(elements, insight, layout)}
269 {todayLine(elements, view, layout)}
270 {daysBlock(elements, insight.days, today, layout)}
271 {quotaLines(elements, insight)}
272 {spendLines(elements, insight)}
273 {lastWeekLine(elements, insight)}
274 </Box>
275 )
276}
277
278function footer({ Text }: Elements, layout: Layout): RenderElement {
279 if (!layout.isWide) return <Text dimColor>│ even pace now · $ API-equivalent, this machine</Text>
280
281 return <Text dimColor>│ marks where an even pace would be now. $ are API-equivalent and count this machine only.</Text>
282}
283
284export function paneTree(elements: Elements, insights: readonly WindowInsight[], today: string, columns: number): RenderElement {
285 const { Box, Text } = elements
286 if (insights.length === 0) {
287 return (
288 <Box flexDirection="column">
289 <Text bold>No quota data yet</Text>
290 <Text dimColor>Needs a subscription and one API response.</Text>
291 </Box>
292 )
293 }
294
295 const layout = layoutFor(columns)
296
297 return (
298 <Box flexDirection="column">
299 <Box flexDirection={layout.isSideBySide ? 'row' : 'column'} columnGap={4}>
300 {insights.map(insight => section(elements, insight, today, layout))}
301 </Box>
302 {footer(elements, layout)}
303 </Box>
304 )
305}
306hooks/quota-size.ts 171 lines1import type { BurnrateCostLedger, BurnrateHourSample } from '../types'
2import { HOUR_MS, isWindowKind, resetsAtOfKey, RETENTION_MS, WINDOW_SECONDS } from './pace'
3
4type HourlyCosts = Readonly<Record<string, number>>
5
6export type WindowRatio = {
7 windowKey: string
8 resetsAt: number
9 usd: number
10 points: number
11 usdPerPoint: number | null
12}
13
14export type QuotaChange = { direction: 'up' | 'down'; percent: number }
15
16const RETENTION_HOURS = RETENTION_MS / HOUR_MS
17const MIN_POINTS = 5
18const CHANGE_THRESHOLD = 0.2
19const MIN_CLOSED_WINDOWS = 2
20
21function costBetween(costs: HourlyCosts, fromHourExclusive: number, toHourInclusive: number): number {
22 let total = 0
23 for (let hour = fromHourExclusive + 1; hour <= toHourInclusive; hour += 1) total += costs[String(hour)] ?? 0
24
25 return total
26}
27
28export function windowRatio(windowKey: string, samples: readonly BurnrateHourSample[], costs: HourlyCosts): WindowRatio | null {
29 const resetsAt = resetsAtOfKey(windowKey)
30 if (resetsAt === null) return null
31
32 const ordered = [...samples].sort((a, b) => a.hour - b.hour)
33 const moves = ordered.flatMap((sample, index) => {
34 const previous = ordered[index - 1]
35 if (previous === undefined) return []
36
37 const gained = sample.pct - previous.pct
38 if (gained <= 0) return []
39
40 return [{ gained, usd: costBetween(costs, previous.hour, sample.hour) }]
41 })
42 const points = moves.reduce((sum, move) => sum + move.gained, 0)
43 const usd = moves.reduce((sum, move) => sum + move.usd, 0)
44 const usdPerPoint = points >= MIN_POINTS && usd > 0 ? usd / points : null
45
46 return { windowKey, resetsAt, usd, points, usdPerPoint }
47}
48
49function startsInCostRetention(ratio: WindowRatio, kind: string, now: number): boolean {
50 if (!isWindowKind(kind)) return false
51
52 const startHour = Math.floor((ratio.resetsAt - WINDOW_SECONDS[kind] * 1_000) / HOUR_MS)
53
54 return startHour >= retainedSince(now)
55}
56
57export function windowRatios(
58 hours: Readonly<Record<string, BurnrateHourSample>>,
59 costs: HourlyCosts,
60 kind: string,
61 now: number,
62): WindowRatio[] {
63 const byWindow = new Map<string, BurnrateHourSample[]>()
64 for (const sample of Object.values(hours)) {
65 if (!sample.windowKey.startsWith(`${kind}@`)) continue
66
67 byWindow.set(sample.windowKey, [...(byWindow.get(sample.windowKey) ?? []), sample])
68 }
69
70 return [...byWindow.entries()]
71 .flatMap(([key, samples]) => {
72 const ratio = windowRatio(key, samples, costs)
73
74 return ratio === null || !startsInCostRetention(ratio, kind, now) ? [] : [ratio]
75 })
76 .sort((a, b) => a.resetsAt - b.resetsAt)
77}
78
79function median(values: readonly number[]): number | null {
80 const sorted = [...values].sort((a, b) => a - b)
81 const middle = Math.floor(sorted.length / 2)
82 const upper = sorted[middle]
83 if (upper === undefined) return null
84 if (sorted.length % 2 === 1) return upper
85
86 const lower = sorted[middle - 1] ?? upper
87
88 return (lower + upper) / 2
89}
90
91export function detectQuotaChange(ratios: readonly WindowRatio[], currentKey: string): QuotaChange | null {
92 const current = ratios.find(ratio => ratio.windowKey === currentKey)
93 if (current?.usdPerPoint == null) return null
94
95 const closed = ratios
96 .filter(ratio => ratio.resetsAt < current.resetsAt)
97 .flatMap(ratio => (ratio.usdPerPoint === null ? [] : [ratio.usdPerPoint]))
98 .reverse()
99 if (closed.length < MIN_CLOSED_WINDOWS) return null
100
101 const [previous, ...older] = closed
102 const baseline = median(older)
103 if (previous === undefined || baseline === null || baseline <= 0) return null
104
105 const currentDeviation = current.usdPerPoint / baseline - 1
106 const previousDeviation = previous / baseline - 1
107 const percent = Math.round(Math.abs(currentDeviation) * 100)
108
109 if (currentDeviation > CHANGE_THRESHOLD && previousDeviation > CHANGE_THRESHOLD) return { direction: 'up', percent }
110 if (currentDeviation < -CHANGE_THRESHOLD && previousDeviation < -CHANGE_THRESHOLD) return { direction: 'down', percent }
111
112 return null
113}
114
115function retainedSince(now: number): number {
116 return Math.floor(now / HOUR_MS) - RETENTION_HOURS
117}
118
119function recentHours(hours: HourlyCosts, now: number): Record<string, number> {
120 const oldest = retainedSince(now)
121
122 return Object.fromEntries(Object.entries(hours).filter(([hour]) => Number(hour) >= oldest))
123}
124
125export function recordCost(ledger: BurnrateCostLedger | null, usd: number, now: number): BurnrateCostLedger {
126 const delta = Math.max(0, usd - (ledger?.lastUsd ?? 0))
127 const hour = String(Math.floor(now / HOUR_MS))
128 const hours = recentHours(ledger?.hours ?? {}, now)
129 if (delta > 0) hours[hour] = (hours[hour] ?? 0) + delta
130
131 return { lastUsd: usd, hours }
132}
133
134export function parseLedger(text: string): BurnrateCostLedger | null {
135 const parsed: unknown = (() => {
136 try {
137 return JSON.parse(text)
138 } catch {
139 return null
140 }
141 })()
142 if (typeof parsed !== 'object' || parsed === null || !('lastUsd' in parsed) || !('hours' in parsed)) return null
143
144 const { lastUsd, hours } = parsed
145 if (typeof lastUsd !== 'number' || typeof hours !== 'object' || hours === null) return null
146
147 const valid = Object.entries(hours).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
148 const label = 'label' in parsed && typeof parsed.label === 'string' ? { label: parsed.label } : {}
149
150 return { lastUsd, hours: Object.fromEntries(valid), ...label }
151}
152
153export function mergeCosts(ledgers: readonly Pick<BurnrateCostLedger, 'hours'>[], now: number): Record<string, number> {
154 const merged: Record<string, number> = {}
155 for (const ledger of ledgers) {
156 for (const [hour, usd] of Object.entries(recentHours(ledger.hours, now))) merged[hour] = (merged[hour] ?? 0) + usd
157 }
158
159 return merged
160}
161
162export function quotaUsdPerPoint(ratios: readonly WindowRatio[], currentKey: string): number | null {
163 const current = ratios.find(ratio => ratio.windowKey === currentKey)
164 if (current === undefined) return null
165 if (current.usdPerPoint !== null) return current.usdPerPoint
166
167 const latestClosed = ratios.filter(ratio => ratio.resetsAt < current.resetsAt && ratio.usdPerPoint !== null).at(-1)
168
169 return latestClosed?.usdPerPoint ?? null
170}
171hooks/report.ts 25 lines1import type { BurnrateHistory } from '../types'
2import { formatDay, formatPct } from './format'
3import { closedPeaks, dailyUsage, WEEK_MS, windowKey, type DayUsage } from './pace'
4
5export type WeekReport = { key: string; peak: number; busiest: DayUsage | null }
6
7export function lastWeekReport(history: BurnrateHistory, resetsAt: number, now: number): WeekReport | null {
8 const last = closedPeaks(history, 'seven_day', resetsAt, now)[0]
9 if (last === undefined || now - last.resetsAt >= WEEK_MS) return null
10
11 const key = windowKey('seven_day', last.resetsAt)
12 const busiest = dailyUsage(history, key, last.peak).reduce<DayUsage | null>(
13 (top, day) => (top === null || day.used > top.used ? day : top),
14 null,
15 )
16
17 return { key, peak: last.peak, busiest }
18}
19
20export function reportText({ peak, busiest }: WeekReport): string {
21 const busiestText = busiest === null ? '' : `, busiest ${formatDay(busiest.date)} at ${formatPct(busiest.used)}`
22
23 return `burnrate · last week peaked ${formatPct(peak)}${busiestText}`
24}
25hooks/format.ts 42 lines1export function formatDuration(seconds: number): string {
2 const minutes = Math.floor(seconds / 60)
3 const hours = Math.floor(minutes / 60)
4 const days = Math.floor(hours / 24)
5
6 if (days > 0) return `${days}d ${hours % 24}h`
7 if (hours > 0) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
8
9 return `${minutes}m`
10}
11
12const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
13
14export function formatClock(ms: number, withWeekday: boolean): string {
15 const date = new Date(ms)
16 const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
17 if (!withWeekday) return time
18
19 return `${WEEKDAYS[date.getDay()] ?? ''} ${time}`
20}
21
22export function formatDay(date: string): string {
23 const [year, month, day] = date.split('-').map(Number)
24 if (year === undefined || month === undefined || day === undefined) return date
25
26 const weekday = WEEKDAYS[new Date(year, month - 1, day).getDay()] ?? ''
27
28 return `${weekday} ${String(day).padStart(2, '0')}`
29}
30
31export function formatPct(value: number): string {
32 if (value >= 10) return `${Math.round(value)}%`
33
34 return `${Math.round(value * 10) / 10}%`
35}
36
37export function formatUsd(usd: number): string {
38 if (usd < 10) return `$${usd.toFixed(2)}`
39
40 return `$${Math.round(usd)}`
41}
42hooks/sessions.ts 30 lines1import type { BurnrateSessionCost } from '../types'
2import { HOUR_MS } from './pace'
3
4type ProjectSpend = { label: string; usd: number }
5
6export type WeekSpend = { sessionUsd: number; projects: ProjectSpend[] }
7
8const TOP_PROJECTS = 5
9
10function usdSince(hours: Readonly<Record<string, number>>, sinceHour: number): number {
11 return Object.entries(hours).reduce((sum, [hour, usd]) => (Number(hour) >= sinceHour ? sum + usd : sum), 0)
12}
13
14export function weekSpend(sessions: readonly BurnrateSessionCost[], since: number, currentId: string): WeekSpend {
15 const sinceHour = Math.floor(since / HOUR_MS)
16 const byLabel = new Map<string, number>()
17 for (const session of sessions) {
18 byLabel.set(session.label, (byLabel.get(session.label) ?? 0) + usdSince(session.hours, sinceHour))
19 }
20
21 const current = sessions.find(session => session.id === currentId)
22 const projects = [...byLabel]
23 .map(([label, usd]) => ({ label, usd }))
24 .filter(project => project.usd > 0)
25 .sort((a, b) => b.usd - a.usd)
26 .slice(0, TOP_PROJECTS)
27
28 return { sessionUsd: usdSince(current?.hours ?? {}, sinceHour), projects }
29}
30hooks/outlook.ts 46 lines1export type TracePoint = { elapsedSeconds: number; pct: number }
2
3export type PastWindow = { peak: number; before: TracePoint | null; after: TracePoint | null }
4
5export type Outlook = { typical: number; remaining: number | null; traced: number }
6
7type AgedValue = { value: number; age: number }
8
9const MIN_TRACED_WINDOWS = 2
10const RECENCY_HALF_LIFE_WINDOWS = 2
11
12function weightedMean(values: readonly AgedValue[]): number | null {
13 if (values.length === 0) return null
14
15 const weights = values.map(({ age }) => 0.5 ** (age / RECENCY_HALF_LIFE_WINDOWS))
16 const total = weights.reduce((sum, weight) => sum + weight, 0)
17 const weighted = values.reduce((sum, { value }, index) => sum + value * (weights[index] ?? 0), 0)
18
19 return weighted / total
20}
21
22function pctAtElapsed(past: PastWindow, elapsedSeconds: number, windowSeconds: number): number | null {
23 if (past.before === null && past.after === null) return null
24
25 const from = past.before ?? { elapsedSeconds: 0, pct: 0 }
26 const to = past.after ?? { elapsedSeconds: windowSeconds, pct: past.peak }
27 const span = to.elapsedSeconds - from.elapsedSeconds
28 if (span <= 0) return from.pct
29
30 return from.pct + ((to.pct - from.pct) * (elapsedSeconds - from.elapsedSeconds)) / span
31}
32
33export function outlookOf(pastWindows: readonly PastWindow[], elapsedSeconds: number, windowSeconds: number): Outlook | null {
34 const typical = weightedMean(pastWindows.map((past, age) => ({ value: past.peak, age })))
35 if (typical === null) return null
36
37 const remaining = pastWindows.flatMap((past, age) => {
38 const pctThen = pctAtElapsed(past, elapsedSeconds, windowSeconds)
39
40 return pctThen === null ? [] : [{ value: Math.max(0, past.peak - pctThen), age }]
41 })
42 const isTraced = remaining.length >= MIN_TRACED_WINDOWS
43
44 return { typical, remaining: isTraced ? weightedMean(remaining) : null, traced: isTraced ? remaining.length : 0 }
45}
46types/index.d.ts 61 lines1export type BurnrateReading = {
2 kind: string
3 percentUsed: number
4 resetsAt?: string
5}
6
7export type BurnratePeak = {
8 kind: string
9 resetsAt: number
10 peak: number
11}
12
13export type BurnrateDayStart = {
14 windowKey: string
15 date: string
16 pct: number
17 seenAt: number
18}
19
20export type BurnrateHourSample = {
21 windowKey: string
22 hour: number
23 pct: number
24}
25
26export type BurnrateHistory = {
27 peaks: Record<string, BurnratePeak>
28 dayStarts: Record<string, BurnrateDayStart>
29 hours: Record<string, BurnrateHourSample>
30}
31
32export type BurnrateCostLedger = {
33 lastUsd: number
34 hours: Record<string, number>
35 label?: string
36}
37
38export type BurnrateSessionCost = {
39 id: string
40 label: string
41 hours: Record<string, number>
42}
43
44export type BurnrateAlertState = Record<string, { verdict: string; notified: boolean }>
45
46export type BurnrateSnapshot = {
47 readings: BurnrateReading[]
48 history: BurnrateHistory
49 costs: Record<string, number>
50 sessions: BurnrateSessionCost[]
51 sessionId: string
52 model: string
53 now: number
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 burnrate: { snapshot: BurnrateSnapshot }
59 }
60}
61