Live session and weekly usage gauges under your prompt, fiery nags when your weekly limit is about to go unspent, and a friendly "slow down" when you burn…

<img src="assets/banner.svg" alt="Burn Baby Burn: a Claude Code mod that nags you, with fire, when your weekly limit is about to go to waste" width="100%">
<a href="https://claude.ai/directory"><img src="https://img.shields.io/badge/Claude%20plugin%20directory-Burn%20Baby%20Burn-e8342c?style=flat-square&labelColor=161026" alt="Listed in the Claude plugin directory"></a> <img src="https://img.shields.io/badge/version-0.4.1-ff8c1a?style=flat-square&labelColor=161026" alt="Version 0.4.1"> <img src="https://img.shields.io/badge/Claude%20Code-2.1.302%2B-ffd640?style=flat-square&labelColor=161026" alt="Requires Claude Code 2.1.302 or later"> <img src="https://img.shields.io/badge/network-none-b4a9c9?style=flat-square&labelColor=161026" alt="Sends nothing over the network"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-7d7196?style=flat-square&labelColor=161026" alt="MIT license"></a>
<a href="#install">Install</a> · <a href="#the-closer-the-reset-the-hotter-it-gets">How it nags</a> · <a href="#slow-down">Slow down</a> · <a href="#what-it-does-on-your-machine">What it does on your machine</a> · <a href="#tweak">Tweak</a>
Your weekly usage limit resets whether you used it or not. Burn Baby Burn keeps live gauges for your session and week under the prompt, and otherwise stays quiet for most of the week. When the reset is close and too much of your limit is still unused, it nags you to use it. Burn through it too fast instead, and it kindly tells you to slow down and touch grass.
<img src="assets/demo.svg" alt="Claude Code with Burn Baby Burn: a toast nags you, /burn replies with the weekly status and a quote, and under the prompt hint the usage line shows two flames, a grey session gauge (38% used, resets in 2h 12m) and an orange week gauge (59% used, resets in 16h 4m)" width="100%">
| Usage line | 🔥🔥 session ▰▰▰▱▱▱▱▱ 38% ↻ 2h 12m week ▰▰▰▰▰▱▱▱ 59% ↻ 16h 4m: how much of each window is used and when it resets, on its own row under Claude Code's hint line, always on and counting down even while you're idle. It's coloured by state (see Colours). |
| Toasts | A different quote each time, for 4 seconds, more often as the reset nears. |
| Slow down | Burning through your session or week much faster than it refills? A 🧯 toast tells you to touch grass, once per window. |
/burn | Shows the status whenever you ask, and has something to say outside the window, when you're going too fast, before your first prompt and on accounts with no weekly limit. |
<img src="assets/week.svg" alt="The week before the reset, to scale: calm with no nags for the first 4 days; smoulder from 72 hours out while 25% or more is left, nagging every 6 hours; blaze from 24 hours out while 15% or more is left, every 2 hours; inferno in the last 10 hours while 5% or more is left, every 30 minutes. Slow down can fire any time, unless a burn level applies" width="100%">
<img src="assets/tiers.svg" alt="The nags get hotter as the reset nears: one flame every 6 hours from 3 days out while 25% or more of the week is left, two flames every 2 hours from 1 day out while 15% or more is left, three flames every 30 minutes in the last 10 hours while 5% or more is left" width="100%">
It only nags in the last 72 hours before your weekly reset, and only while enough of the week is left: 25% from 3 days out, 15% in the last day, 5% in the last 10 hours. Use the limit up and it goes quiet again.
Tokens don't roll over. Neither does regret.<br> We didn't start the fire, but 41% of your week says you should.<br> Ask Claude to rewrite it in Rust. You have 38% to spare.
There are 33 nag quotes, 11 per tier, 31 slow-down quotes, plus replies for /burn before your first prompt, outside the window, and on pay-per-use accounts, where there's no weekly limit to burn. They're all in hooks/burn.ts.
The opposite problem: a window counts as used too fast when at least 50% of the session (or 40% of the week) is gone and you're using it 1.5 times faster than the clock runs. At that pace it runs dry with a third of the window still to go. You get one 🧯 toast per window, never two slow-down toasts within 30 minutes, and /burn says the same (🧯 92% of the session gone · resets in 3h 40m).
Easy there, bro. 92% of your session gone and 3h 40m to the reset. Hydrate, touch grass.<br> Who are you and what did you do with my quota? 80% gone, 3d 12h left.<br> I usually yell 'burn'. Today it's 'hold my extinguisher'. 80% gone, 3d 12h left. 🧯
<img src="assets/slow-down.svg" alt="A slow-down moment in Claude Code: a light blue toast says ‘Easy there, bro. 92% of your session gone and 3h 40m to the reset. Hydrate, touch grass.’, /burn replies ‘92% of the session gone, resets in 3h 40m’ with a quote, and under the prompt hint the usage line shows a fire extinguisher, a light blue session gauge at 92% and a grey week gauge at 35%" width="100%">
It never says "burn" and "slow down" at once: while a burn level applies, only the burn nags speak.
<img src="assets/usage-states.svg" alt="The usage line in each state: calm, all grey; smoulder, one flame and a yellow week gauge; blaze, two flames and an orange week gauge; inferno, three flames and a red week gauge; slow down, a fire extinguisher and a light blue session gauge" width="100%">
The line is grey when calm. Near the reset, the flames and the week gauge take the tier's colour: yellow, orange, then red. When you're going too fast, the 🧯 and that window's gauge turn light blue. The colours are Claude Code theme keys, so they follow your light or dark theme.
The coloured row is drawn in the terminal and the desktop app. Where a surface can't draw it (VS Code, the mobile app), the same line also shows as a plain status line, and its 🔥 or 🧯 still tells the state.
You need Claude Code 2.1.302 or later and a plan with a weekly limit. Burn Baby Burn is listed in the Claude plugin directory:
claude plugin install burn-baby-burn@anthropic-plugin-directory --scope user
In Claude Code, run /plugin and search for "Burn Baby Burn", or install it from claude.ai/directory.
This repo is also its own marketplace:
claude plugin marketplace add qorvyx/burn-baby-burn
claude plugin install burn-baby-burn@burn-baby-burn --scope user
To try it without installing:
claude --plugin-dir /path/to/burn-baby-burn
| Reads | Writes | Network | Commands |
|---|---|---|---|
The session's rate limits (only the five-hour and weekly windows), which surfaces the session is drawn on, and the clock, re-checked once a minute. For /burn, also whether Claude has answered yet in this session, to tell a fresh session from an account with no weekly limit. | Two keys in Claude Code's plugin store: lastNag (when it last nagged, and at which tier) and lastSlow (when it last said slow down, per window) | None | None |
It draws a row under the prompt hint, shows toasts and, where a surface needs it, a status line, and adds the /burn command. It touches no other files.
Its hooks (hooks/register.tsx) pass events on unchanged, with two exceptions: the prompt hint keeps Claude Code's own drawing and gets one row added under it, and /burn, its own command, is answered by the mod:
session.start: registers /burn, takes the first usage reading and starts the one-minute re-check.session.measure: re-checks when Claude Code reports new rate limits.ui.render (only the PromptHint component, the row under the prompt): draws Claude Code's own hint, then the coloured usage line under it.command.run (only for /burn): answers with the current status; every other command is left alone.[!IMPORTANT] A mod runs inside Claude Code with the same access Claude Code has. Read the code (
hooks/) before installing.
The rules are in hooks/burn.ts. The TIERS table sets when each level starts, how often it nags, and how much of the week must be left for it to nag.
const TIERS = [
{ tier: 'inferno', within: 10 * HOUR, every: 30 * 60 * 1000, minLeft: 5 },
{ tier: 'blaze', within: 24 * HOUR, every: 2 * HOUR, minLeft: 15 },
{ tier: 'smoulder', within: 72 * HOUR, every: 6 * HOUR, minLeft: 25 },
]
SLOW sets when a window counts as used too fast: how much must be used first (minUsed), and how much faster than the clock it must run (pace). SLOW_GAP_MS is the least time between two slow-down toasts, and SHADE_COLOR maps each state to a Claude Code theme key. The toast length, TOAST_MS, is in hooks/register.tsx.
const SLOW = [
{ kind: 'five_hour', windowMs: 5 * HOUR, minUsed: 50, pace: 1.5 },
{ kind: 'seven_day', windowMs: 7 * 24 * HOUR, minUsed: 40, pace: 1.5 },
]
<sub>MIT licensed. Now go burn some tokens. 🔥</sub>
hooks/register.tsx 140 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 API,
5 CALM,
6 SHADE_COLOR,
7 WARMUP,
8 WINDOW_HOURS,
9 assess,
10 isNagDue,
11 isSlowDue,
12 pick,
13 quote,
14 readLastNag,
15 readLastSlow,
16 slowDowns,
17 slowQuote,
18 slowText,
19 statusText,
20 usageLine,
21 usageView,
22 weekly,
23} from './burn'
24import type { Limit, UsageView } from './burn'
25
26const LAST_NAG = 'lastNag'
27const LAST_SLOW = 'lastSlow'
28// Rate limits only change when usage moves, so a timer keeps the countdown and nags going while idle.
29const RECHECK_MS = 60_000
30// How long a nag toast stays, set here so it doesn't follow changes to the default.
31const TOAST_MS = 4_000
32// Surfaces that draw the coloured row under the prompt hint; any other gets the plain status line.
33const COLOUR_SURFACES: readonly string[] = ['terminal', 'desktop']
34
35// The latest reading, re-assessed by the timer between measures.
36let latest: readonly Limit[] = []
37// What the coloured row under the prompt hint draws; undefined draws nothing.
38let view: UsageView | undefined
39
40async function check($: EngineInterface, limits: readonly Limit[]) {
41 latest = limits
42 const now = await $.clock.now()
43 const burn = assess(weekly(limits), now)
44 const slow = slowDowns(limits, now, burn)
45
46 view = usageView(limits, now, burn, slow)
47 $.ui.invalidate('ui.render')
48 // The plain line is only needed where some surface can't draw the coloured row.
49 const surfaces = await $.session.surfaces()
50 const plainNeeded = surfaces.length === 0 || surfaces.some(s => !COLOUR_SURFACES.includes(s))
51 $.ui.status(plainNeeded ? usageLine(limits, now, burn, slow) : undefined)
52
53 // Kept in $.store so a nag isn't repeated by every new session.
54 if (burn !== null) {
55 const last = readLastNag(await $.store.get(LAST_NAG))
56 if (!isNagDue(burn, now, last)) return
57 $.ui.toast(quote(burn, now), { timeoutMs: TOAST_MS })
58 await $.store.set(LAST_NAG, { at: now, tier: burn.tier })
59 return
60 }
61
62 // No burn tier applies: one slow-down toast at most, the session first.
63 const lastSlow = readLastSlow(await $.store.get(LAST_SLOW))
64 const due = slow.find(p => isSlowDue(p, now, lastSlow))
65 if (due === undefined) return
66 $.ui.toast(slowQuote(due, now), { timeoutMs: TOAST_MS })
67 await $.store.set(LAST_SLOW, { ...lastSlow, [due.kind]: now })
68}
69
70// Checks run one after another: a measure and the timer's tick could otherwise both read the
71// store before either writes it, and toast the same nag twice.
72let queue: Promise<void> = Promise.resolve()
73
74// A nag is never worth breaking the session for: a failed check is dropped.
75function safeCheck($: EngineInterface, limits: readonly Limit[]): Promise<void> {
76 queue = queue.then(() => check($, limits)).catch(() => {
77 // Nothing to recover; the next measure or tick tries again.
78 })
79 return queue
80}
81
82export const register: Register = on => {
83 on('session.start', async ($, e, next) => {
84 const started = await next(e)
85 await $.command.register({ name: 'burn', description: 'How much of the weekly limit is left to burn' })
86 // Empty until the first API response of the session; session.measure follows.
87 await safeCheck($, (await $.session.usage()).rateLimits)
88 $.clock.every(RECHECK_MS, () => void safeCheck($, latest))
89 return started
90 })
91
92 on('session.measure', async ($, e, next) => {
93 if (e.changed.includes('rateLimits')) await safeCheck($, e.rateLimits)
94 return next(e)
95 })
96
97 // The usage line in colour, on its own row under Claude Code's hint line, which is drawn unchanged.
98 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
99 const engine = await next(e)
100 const shown = view
101 if (shown === undefined) return engine
102 const { Box, Text } = $.ui.resolve(e)
103 return (
104 <Box flexDirection="column">
105 {engine}
106 {/* The marker rides on the first gauge, so a narrow terminal never leaves it alone on a row. */}
107 <Box flexWrap="wrap" columnGap={3}>
108 {shown.gauges.map((g, i) => {
109 const gaugeText = <Text color={SHADE_COLOR[g.shade]}>{g.text}</Text>
110 const marker = i === 0 ? shown.marker : undefined
111 if (marker === undefined) return <Text wrap="truncate-end">{gaugeText}</Text>
112 return (
113 <Text wrap="truncate-end">
114 <Text color={SHADE_COLOR[marker.shade]}>{`${marker.text} `}</Text>
115 {gaugeText}
116 </Text>
117 )
118 })}
119 </Box>
120 </Box>
121 )
122 })
123
124 on('command.run', { command: 'burn' }, async $ => {
125 const now = await $.clock.now()
126 const usage = await $.session.usage()
127 const limit = weekly(usage.rateLimits)
128 if (limit === undefined) {
129 // Subscriptions report the weekly window with the first answer; still none after one means an API key.
130 const hasAnswered = (usage.context.tokens ?? 0) > 0 || (usage.cost?.usd ?? 0) > 0
131 return { text: pick(hasAnswered ? API : WARMUP, now) }
132 }
133 const burn = assess(limit, now)
134 if (burn !== null) return { text: `${statusText(burn)}\n${quote(burn, now)}` }
135 const fast = slowDowns(usage.rateLimits, now, burn)[0]
136 if (fast !== undefined) return { text: `${slowText(fast)}\n${slowQuote(fast, now)}` }
137 return { text: `${Math.round(100 - limit.percentUsed)}% of the week left. ${pick(CALM, now).replaceAll('{window}', String(WINDOW_HOURS))}` }
138 })
139}
140hooks/burn.ts 318 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Tier = 'smoulder' | 'blaze' | 'inferno'
3export type Burn = { tier: Tier; left: number; msLeft: number }
4
5const HOUR = 60 * 60 * 1000
6
7// How long before the reset each tier starts, how often it may nag, and the share of the week
8// that must still be left for it to nag at all (less as the reset nears).
9const TIERS: { tier: Tier; within: number; every: number; minLeft: number }[] = [
10 { tier: 'inferno', within: 10 * HOUR, every: 30 * 60 * 1000, minLeft: 5 },
11 { tier: 'blaze', within: 24 * HOUR, every: 2 * HOUR, minLeft: 15 },
12 { tier: 'smoulder', within: 72 * HOUR, every: 6 * HOUR, minLeft: 25 },
13]
14
15const QUOTES: Record<Tier, string[]> = {
16 smoulder: [
17 'Burn, baby, burn. {left} of your week is just sitting there.',
18 "Tokens don't roll over. Neither does regret.",
19 "Your weekly quota called. It's feeling unloved.",
20 'Unused tokens are just compute crying quietly.',
21 'Light my fire: {left} left, reset in {time}.',
22 "{left} of your week is still in the fridge. It expires in {time}.",
23 "Your tokens are like gym membership: paid for and unused.",
24 "Fun fact: unused tokens don't go to token heaven.",
25 "Your context window is looking a bit empty. Feed it.",
26 "Somewhere, a backlog is waiting for you. {left} could clear it.",
27 "Claude is getting bored. Give it a refactor.",
28 ],
29 blaze: [
30 'Disco inferno: {left} left and under a day to go.',
31 "Use it or lose it. And you're about to lose it.",
32 'Somewhere a GPU is idle because of you.',
33 'We didn\'t start the fire, but {left} of your week says you should.',
34 'Ship it like the reset is tomorrow. Because it is.',
35 "{left} left and {time} to go. Even your linter is judging you.",
36 "That TODO from 2023? Now's the time. {left} says so.",
37 "Pretend it's Friday at 5 pm and the demo is Monday.",
38 "Your weekly quota is packing its bags. {time} until it leaves.",
39 "Write the tests you promised yourself. You have {left} of a week to do it.",
40 "Under a day left. Ask Claude something ambitious.",
41 ],
42 inferno: [
43 'BURN BABY BURN! {left} left, reset in {time}!',
44 'Last call at the token bar. Order big.',
45 "It's the final countdown: {time} to burn {left}.",
46 'Refactor something. Anything. Now.',
47 'Great balls of fire! {left} still unspent!',
48 "{time} left. This is not a drill. {left} still unburned!",
49 "Open every repo. Fix everything. GO.",
50 "The tokens are melting. {left} gone in {time}.",
51 "Spend it like it's the last day of vacation money.",
52 "Ask Claude to rewrite it in Rust. You have {left} to spare.",
53 "Midnight sale on tokens: everything must go in {time}.",
54 ],
55}
56
57export function weekly(limits: readonly Limit[]): Limit | undefined {
58 return limits.find(l => l.kind === 'seven_day')
59}
60
61// The windows the usage line shows, in order, under the names /usage gives them.
62// The one place a window gets its name: the gauges and the slow-down text both read it.
63const LABEL: Record<PaceKind, string> = { five_hour: 'session', seven_day: 'week' }
64const SHOWN: readonly PaceKind[] = ['five_hour', 'seven_day']
65const GAUGE_CELLS = 8
66
67// A fuel gauge of how much of a window is used: ▰ spent, ▱ still to burn.
68export function gauge(percentUsed: number): string {
69 const spent = Math.min(GAUGE_CELLS, Math.max(0, Math.round((percentUsed / 100) * GAUGE_CELLS)))
70 return '▰'.repeat(spent) + '▱'.repeat(GAUGE_CELLS - spent)
71}
72
73// What colours a part of the usage line: calm, a burn tier, or the slow-down nudge.
74export type Shade = 'calm' | Tier | 'slow'
75
76// Theme keys, so the colours follow the person's light or dark theme. Plan and auto-accept
77// colours are left out: Claude Code's own mode pills use them on the same rows.
78export const SHADE_COLOR: Record<Shade, 'inactive' | 'warning' | 'claude' | 'error' | 'suggestion'> = {
79 calm: 'inactive',
80 smoulder: 'warning',
81 blaze: 'claude',
82 inferno: 'error',
83 slow: 'suggestion',
84}
85
86export type LinePart = { text: string; shade: Shade }
87// The usage line: an optional marker ahead of it, then one gauge per window.
88export type UsageView = { marker?: LinePart; gauges: LinePart[] }
89
90const EXTINGUISHER = '🧯'
91
92// The usage line in coloured parts: a marker (the tier's flames near the weekly reset, or the
93// extinguisher for a window used too fast), then each window's gauge and reset. Only the week
94// gauge takes the burn tier's colour; a window used too fast takes the slow-down colour.
95// A window whose reset has passed is a stale reading and is left out; with none left, there is no line.
96export function usageView(limits: readonly Limit[], now: number, burn: Burn | null, slow: readonly Pace[] = []): UsageView | undefined {
97 const gauges = SHOWN.flatMap((kind): LinePart[] => {
98 const limit = limits.find(l => l.kind === kind)
99 if (limit === undefined) return []
100 const msLeft = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) - now
101 if (msLeft <= 0) return []
102 const reset = Number.isNaN(msLeft) ? '' : ` ↻ ${formatLeft(msLeft)}`
103 const shade: Shade = burn && kind === 'seven_day' ? burn.tier : slow.some(p => p.kind === kind) ? 'slow' : 'calm'
104 return [{ text: `${LABEL[kind]} ${gauge(limit.percentUsed)} ${Math.round(limit.percentUsed)}%${reset}`, shade }]
105 })
106 if (gauges.length === 0) return undefined
107 if (burn) return { marker: { text: FLAMES[burn.tier], shade: burn.tier }, gauges }
108 if (gauges.some(g => g.shade === 'slow')) return { marker: { text: EXTINGUISHER, shade: 'slow' }, gauges }
109 return { gauges }
110}
111
112// The same line as plain text, for surfaces that can't draw the coloured row.
113export function usageLine(limits: readonly Limit[], now: number, burn: Burn | null, slow: readonly Pace[] = []): string | undefined {
114 const view = usageView(limits, now, burn, slow)
115 if (view === undefined) return undefined
116 return (view.marker ? `${view.marker.text} ` : '') + view.gauges.map(g => g.text).join(' ')
117}
118
119export function assess(limit: Limit | undefined, now: number): Burn | null {
120 if (limit?.resetsAt === undefined) return null
121 const msLeft = Date.parse(limit.resetsAt) - now
122 if (Number.isNaN(msLeft) || msLeft <= 0) return null
123 // Tiers are ordered tightest first, so the first match is the hottest.
124 const match = TIERS.find(t => msLeft <= t.within)
125 // The exact share left meets the threshold, so 4.6% doesn't pass as 5%; only the shown number is rounded.
126 const exactLeft = 100 - limit.percentUsed
127 if (match === undefined || exactLeft < match.minLeft) return null
128 return { tier: match.tier, left: Math.round(exactLeft), msLeft }
129}
130
131export function nagEvery(tier: Tier): number {
132 return TIERS.find(t => t.tier === tier)?.every ?? 6 * HOUR
133}
134
135// The warning window: the widest tier's reach before the reset.
136export const WINDOW_HOURS = Math.max(...TIERS.map(t => t.within)) / HOUR
137
138export type LastNag = { at: number; tier: Tier }
139
140// Store values are untrusted (older versions, hand edits): anything malformed counts as no nag yet.
141export function readLastNag(value: unknown): LastNag | undefined {
142 if (typeof value !== 'object' || value === null) return undefined
143 const { at, tier } = value as Record<string, unknown>
144 if (typeof at !== 'number' || !Number.isFinite(at)) return undefined
145 if (!TIERS.some(t => t.tier === tier)) return undefined
146 return { at, tier: tier as Tier }
147}
148
149// Tiers only get hotter as the reset nears, so a tier change always nags; otherwise once per the tier's interval.
150export function isNagDue(burn: Burn, now: number, last: LastNag | undefined): boolean {
151 if (last === undefined || last.tier !== burn.tier) return true
152 return now - last.at >= nagEvery(burn.tier)
153}
154
155export function formatLeft(ms: number): string {
156 const hours = Math.floor(ms / HOUR)
157 const days = Math.floor(hours / 24)
158 if (days > 0) return `${days}d ${hours % 24}h`
159 if (hours > 0) return `${hours}h ${Math.floor((ms % HOUR) / 60000)}m`
160 return `${Math.max(1, Math.floor(ms / 60000))}m`
161}
162
163// Picks a line that changes every minute, so repeat nags don't repeat lines.
164export function pick(lines: readonly string[], now: number): string {
165 return lines[Math.floor(now / 60000) % lines.length] ?? lines[0] ?? ''
166}
167
168export function quote(burn: Burn, now: number): string {
169 return pick(QUOTES[burn.tier], now)
170 .replaceAll('{left}', `${burn.left}%`)
171 .replaceAll('{time}', formatLeft(burn.msLeft))
172}
173
174// /burn outside the warning window.
175export const CALM = [
176 "The fire is just a pilot light for now. Go build something.",
177 "Plenty of fuel in the tank. I'll start yelling {window} h before the reset.",
178 "Relax. Your tokens are safe… for now. 😈",
179 "Too early to panic. Come back in a few days.",
180 "The kindling is stacked. The match is ready. Not yet, though.",
181 "I'm on standby. Spend wisely, and I'll keep quiet.",
182]
183
184// /burn when Claude has answered but no weekly limit came back: an API key, pay per token.
185export const API = [
186 "No weekly limit here. You pay per token, so I'm the one who should be scared. 💸",
187 "API user detected. There's no quota to burn, only your credit card.",
188 "You don't have a weekly limit. You have an invoice.",
189 "Nothing resets for you. Your bill just keeps going up.",
190 "I'm a weekly-limit nagger with no weekly limit. Existential crisis. 🔥🫠",
191 "Every token counts on your bill. Burn responsibly.",
192]
193
194// /burn before the session's first answer, when there is no reading yet.
195export const WARMUP = [
196 "I can't see your fuel gauge yet. Say something to Claude and I'll take a look.",
197 "Warming up the matches… send a prompt and I'll check your tank.",
198 "No smoke without fire, and no reading without a prompt. Ask Claude anything.",
199 "I'm blind until Claude answers once. Go on, poke it.",
200 "Still lighting the stove. One prompt and I'll know how much you've got to burn.",
201]
202
203const FLAMES: Record<Tier, string> = { smoulder: '🔥', blaze: '🔥🔥', inferno: '🔥🔥🔥' }
204
205export function statusText(burn: Burn): string {
206 return `${FLAMES[burn.tier]} ${burn.left}% of the week left · resets in ${formatLeft(burn.msLeft)}`
207}
208
209// --- Slow down: a window used much faster than it refills ---
210
211export type PaceKind = 'five_hour' | 'seven_day'
212export type Pace = { kind: PaceKind; used: number; msLeft: number }
213
214// For each window: its length, how much must be used before pace counts, and how far used %
215// must run ahead of elapsed %. A pace of 1.5 runs dry with a third of the window still to go,
216// so the reset is always far off when it fires (used < 100 means elapsed < 2/3 of the window).
217const SLOW: { kind: PaceKind; windowMs: number; minUsed: number; pace: number }[] = [
218 { kind: 'five_hour', windowMs: 5 * HOUR, minUsed: 50, pace: 1.5 },
219 { kind: 'seven_day', windowMs: 7 * 24 * HOUR, minUsed: 40, pace: 1.5 },
220]
221// The least time between two slow-down toasts, of either window.
222export const SLOW_GAP_MS = 30 * 60 * 1000
223
224export function pace(limit: Limit | undefined, now: number): Pace | null {
225 const rule = SLOW.find(r => r.kind === limit?.kind)
226 if (rule === undefined || limit?.resetsAt === undefined) return null
227 const msLeft = Date.parse(limit.resetsAt) - now
228 if (Number.isNaN(msLeft) || msLeft <= 0 || msLeft >= rule.windowMs) return null
229 const used = limit.percentUsed
230 const elapsed = 100 * (1 - msLeft / rule.windowMs)
231 if (used < rule.minUsed || used >= 100 || used < rule.pace * elapsed) return null
232 return { kind: rule.kind, used: Math.round(used), msLeft }
233}
234
235// The windows used too fast, session first. Never while a burn tier applies: the mod doesn't say
236// "burn" and "slow down" at once, so a burn verdict silences the nudge for both windows.
237export function slowDowns(limits: readonly Limit[], now: number, burn: Burn | null): Pace[] {
238 if (burn !== null) return []
239 return SLOW.flatMap(r => {
240 const p = pace(limits.find(l => l.kind === r.kind), now)
241 return p ? [p] : []
242 })
243}
244
245// When each window last got a slow-down toast.
246export type LastSlow = Partial<Record<PaceKind, number>>
247
248// Store values are untrusted: anything malformed counts as no toast yet.
249export function readLastSlow(value: unknown): LastSlow {
250 if (typeof value !== 'object' || value === null) return {}
251 const out: LastSlow = {}
252 for (const { kind } of SLOW) {
253 const at = (value as Record<string, unknown>)[kind]
254 if (typeof at === 'number' && Number.isFinite(at)) out[kind] = at
255 }
256 return out
257}
258
259// Once per window (the last toast for it came before this window began), and never within
260// SLOW_GAP_MS of another slow-down toast.
261export function isSlowDue(p: Pace, now: number, last: LastSlow): boolean {
262 const rule = SLOW.find(r => r.kind === p.kind)
263 if (rule === undefined) return false
264 const windowStart = now + p.msLeft - rule.windowMs
265 const mine = last[p.kind]
266 if (mine !== undefined && mine >= windowStart) return false
267 const latestAny = Math.max(-Infinity, ...Object.values(last))
268 return now - latestAny >= SLOW_GAP_MS
269}
270
271const SLOW_QUOTES: Record<PaceKind, string[]> = {
272 five_hour: [
273 'Easy there, bro. {used} of your session gone and {time} to the reset. Hydrate, touch grass.',
274 'Whoa, cowboy. {used} of the session already, {time} to go. Pace yourself.',
275 '{used} of your session in record time. The fire marshal would like a word.',
276 'Slow burn, baby. {used} gone and the session resets in {time}.',
277 "{used} already? Stretch, grab a coffee, let the tokens cool for a bit.",
278 "It's a marathon, not a sprint. {used} spent, {time} on the clock.",
279 'Even I think that\'s too hot. {used} of your session, {time} to the reset. 🧯',
280 'Have a life. Touch grass. The session resets in {time}.',
281 'Go outside. The sun misses you. {used} of the session can wait.',
282 'Drink some water. {used} of your session is gone and you look thirsty.',
283 'Call your mom. She asked about you. The session refills in {time}.',
284 'Pet a dog. Any dog. {used} of the session deserves a break.',
285 'Nap time. {used} of your session gone, {time} to the reset.',
286 'Your chair needs a break too. Stand up for {time}.',
287 "The tokens aren't going anywhere. Well, {used} of them did.",
288 ],
289 seven_day: [
290 "Easy, tiger. {used} of the week gone and it doesn't reset for {time}.",
291 '{used} of your week already? Save some fire for the weekend.',
292 'At this pace your week ends before the week does. {time} to the reset.',
293 'Slow down, champ. {used} spent, {time} to go. Even dragons pace their fire.',
294 'Who are you and what did you do with my quota? {used} gone, {time} left.',
295 '{used} of the week, {time} to go. Maybe let Claude think before it types.',
296 'Rationing mode: {used} of the week is toast and the reset is {time} away.',
297 "I usually yell 'burn'. Today it's 'hold my extinguisher'. {used} gone, {time} left. 🧯",
298 'Have a life. Touch grass. The week resets in {time}.',
299 'Go outside. The sun has been asking about you.',
300 '{used} of the week gone. Drink water, eat a vegetable, come back later.',
301 'Call your mom. Tell her you used {used} of your week already.',
302 'Pet a dog. Read a book. The week still has {time} to go.',
303 'Take a nap. Your weekly quota will thank you in {time}.',
304 "Your keyboard needs a weekend. {used} of the week says so.",
305 "The tokens aren't going anywhere. Neither is the reset, for {time}.",
306 ],
307}
308
309export function slowQuote(p: Pace, now: number): string {
310 return pick(SLOW_QUOTES[p.kind], now)
311 .replaceAll('{used}', `${p.used}%`)
312 .replaceAll('{time}', formatLeft(p.msLeft))
313}
314
315export function slowText(p: Pace): string {
316 return `${EXTINGUISHER} ${p.used}% of the ${LABEL[p.kind]} gone · resets in ${formatLeft(p.msLeft)}`
317}
318