Guards the 5h usage window: caps expensive-tier spawns per turn, keeps Fable spawns manual-only, draws a colour-coded usage gauge above the prompt (5h bar…

Guards the 5-hour usage window. Three parts:
Agent tool:sonnet/haiku. Change the limit with EXPENSIVE_PER_TURN in hooks/logic.ts.fableManualOnly, default on): a Fable-tier spawn is denied unless your prompt this turn asks for it ("ask fable", "use fable advisor", "fable, review this"). A passing mention ("is fable cheaper?") or a negation ("don't use fable") doesn't unlock it. Sessions already running Fable are exempt.model, the agent file's frontmatter, CLAUDE_CODE_SUBAGENT_MODEL, the parent session), and built-in agents' models are learned from what they actually ran on.handoffBandTokens (default 150K): [h] runs /session-handoff, then /clear once the handoff file has been written; [i] hides it until the context grows another handoffBandStep. It expects a /session-handoff skill, e.g. the one in claude-code-skills; point handoffFile at whatever file yours writes.Optional toasts (off by default): cacheNudge before the prompt cache expires, heavyTurnAlert when one turn burns more than heavyTurnPercent of the window or the window crosses windowAlertPercent.
hooks/logic.ts: pure parts (tier mapping, Fable-request wording, gauge layout, band rules)hooks/register.tsx: hooks, gauge and band rendering, the spawn guardtests/: claude plugin test <this folder>hooks/register.tsx 452 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderChildren, PluginOptions, Register } from 'claude-code'
3
4import {
5 bandCacheText,
6 bandLayout,
7 bandVisible,
8 cacheMinutesLeft,
9 cacheNudge,
10 EXPENSIVE_PER_TURN,
11 frontmatterModel,
12 gaugeFit,
13 gaugeSegments,
14 HANDOFF_LABEL,
15 heavyAlerts,
16 IGNORE_LABEL,
17 isHandoffDraft,
18 learnedModel,
19 requestsFable,
20 resolveModel,
21 tierOf,
22 turnDelta,
23 type Tone,
24} from './logic'
25
26// Everything lives in $.state so a hot reload keeps the turn's count.
27const prompt = atom({ plugin: 'burn-guard', key: 'prompt' } as const, '')
28const spawns = atom({ plugin: 'burn-guard', key: 'spawns' } as const, 0)
29const turnBase = atom({ plugin: 'burn-guard', key: 'turnBase' } as const, null)
30const lastResponseAt = atom({ plugin: 'burn-guard', key: 'lastResponseAt' } as const, null)
31const nudgedFor = atom({ plugin: 'burn-guard', key: 'nudgedFor' } as const, null)
32const lastFiveHour = atom({ plugin: 'burn-guard', key: 'lastFiveHour' } as const, null)
33const heavyAlerted = atom({ plugin: 'burn-guard', key: 'heavyAlerted' } as const, false)
34/** The usage readings the gauge row draws; written only when one changes, so it redraws at most once a minute. */
35const readings = atom({ plugin: 'burn-guard', key: 'readings' } as const, null)
36
37/** The manifest's userConfig, typed; both alerts default off. */
38type Config = {
39 nudgeLeadMs: number | null
40 heavy: { turnPct: number; windowPct: number } | null
41 fableManualOnly: boolean
42}
43
44function readConfig(options: PluginOptions): Config {
45 const bool = (k: string) => options[k] === true
46 const num = (k: string, d: number) => {
47 const v = options[k]
48 return typeof v === 'number' ? v : d
49 }
50 return {
51 nudgeLeadMs: bool('cacheNudge') ? num('cacheNudgeMinutes', 5) * 60_000 : null,
52 heavy: bool('heavyTurnAlert') ? { turnPct: num('heavyTurnPercent', 5), windowPct: num('windowAlertPercent', 80) } : null,
53 fableManualOnly: options.fableManualOnly !== false,
54 }
55}
56
57/** Text props per gauge tone. */
58const TONE: Record<Tone, { color?: string; dimColor?: boolean }> = {
59 plain: {},
60 dim: { dimColor: true },
61 info: { color: 'cyan' },
62 meta: { color: 'magenta' },
63 ok: { color: 'green' },
64 warn: { color: 'yellow' },
65 bad: { color: 'red' },
66}
67
68const learnedKey = (subagentType: string) => `learned-model:${subagentType}`
69
70async function learned($: EngineInterface, subagentType: string): Promise<string | undefined> {
71 const v = await $.store.get(learnedKey(subagentType))
72 return typeof v === 'string' ? v : undefined
73}
74
75const USER_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
76
77async function fiveHourPercent($: EngineInterface): Promise<number | undefined> {
78 const { rateLimits } = await $.session.usage()
79 return rateLimits.find(r => r.kind === 'five_hour')?.percentUsed
80}
81
82/** Re-read usage and cache warmth for the gauge row, and fire the cache nudge. */
83async function refreshStatus($: EngineInterface, config: Config): Promise<void> {
84 const { rateLimits, context } = await $.session.usage()
85 const five = rateLimits.find(r => r.kind === 'five_hour')
86 const fiveHour = five?.percentUsed
87 // No reading at turn start (first turn of a session): base on the first one seen.
88 if ((await read($, turnBase)) === null && fiveHour !== undefined) await update($, turnBase, () => fiveHour)
89 const now = await $.clock.now()
90 const last = await read($, lastResponseAt)
91 const left = cacheMinutesLeft(now, last)
92 if ((await read($, cacheLeftMin)) !== left) await update($, cacheLeftMin, () => left)
93 const next = {
94 fiveHour: fiveHour ?? null,
95 fiveHourResetsAt: five?.resetsAt ?? null,
96 sevenDay: rateLimits.find(r => r.kind === 'seven_day')?.percentUsed ?? null,
97 contextTokens: context.tokens ?? null,
98 }
99 const prev = await read($, readings)
100 if (prev === null || (Object.keys(next) as (keyof typeof next)[]).some(k => prev[k] !== next[k])) {
101 await update($, readings, () => next)
102 }
103 if (config.nudgeLeadMs !== null) {
104 const nudge = cacheNudge({ now, lastResponseAt: last, nudgedFor: await read($, nudgedFor), leadMs: config.nudgeLeadMs })
105 if (nudge !== undefined) {
106 await update($, nudgedFor, () => last)
107 $.ui.toast(nudge, { timeoutMs: 15_000 })
108 }
109 }
110}
111
112/** The `model:` frontmatter of a subagent definition: project agents first, then user agents. */
113async function definitionModel($: EngineInterface, subagentType: string): Promise<string | undefined> {
114 const home = await $.env.get('HOME')
115 const dirs = [`${await $.session.root()}/.claude/agents`, ...(home ? [`${home}/.claude/agents`] : [])]
116 for (const dir of dirs) {
117 try {
118 return frontmatterModel(await $.fs.read(`${dir}/${subagentType}.md`))
119 } catch {
120 // Not defined in this directory; try the next.
121 }
122 }
123 return undefined
124}
125
126function deny($: EngineInterface, reason: string, toast: string): { deny: string } {
127 $.ui.toast(`burn-guard denied: ${toast}`, { timeoutMs: 8000 })
128 return { deny: `burn-guard: ${reason}` }
129}
130
131// The handoff band: past a context size, offer /session-handoff then /clear.
132// There is deliberately no compaction path. All band state lives in $.state,
133// so a hot reload (a config change, an edit) keeps it.
134
135/** Written by burn-guard's minute tick; the band reads it while drawing, so it re-renders on its own. */
136const cacheLeftMin = atom({ plugin: 'burn-guard', key: 'cacheLeftMin' } as const, null)
137const bandSize = atom({ plugin: 'burn-guard', key: 'bandSize' } as const, null)
138const ignoredAt = atom({ plugin: 'burn-guard', key: 'bandIgnoredAt' } as const, null)
139const quiet = atom({ plugin: 'burn-guard', key: 'bandQuiet' } as const, false)
140const pressedAt = atom({ plugin: 'burn-guard', key: 'handoffPressedAt' } as const, null)
141const handoffDraft = atom({ plugin: 'burn-guard', key: 'handoffDraft' } as const, null)
142
143const HANDOFF_COMMAND = 'session-handoff'
144
145type BandConfig = { threshold: number; step: number; handoffFile: string }
146
147function readBandConfig(options: PluginOptions): BandConfig {
148 const num = (k: string, d: number) => {
149 const v = options[k]
150 return typeof v === 'number' ? v : d
151 }
152 const file = options.handoffFile
153 return {
154 threshold: num('handoffBandTokens', 150_000),
155 step: num('handoffBandStep', 50_000),
156 handoffFile: typeof file === 'string' ? file : '',
157 }
158}
159
160async function handoffPath($: EngineInterface, config: BandConfig): Promise<string | undefined> {
161 if (config.handoffFile !== '') return config.handoffFile
162 const home = await $.env.get('HOME')
163 return home ? `${home}/.claude/handoffs/latest.md` : undefined
164}
165
166/** Turn-end decision: show the band for the context the next request re-sends, or hide it. */
167async function refreshBand($: EngineInterface, config: BandConfig, isInteractive: boolean): Promise<void> {
168 const size = isInteractive ? (await $.session.usage()).context.tokens : undefined
169 const show = bandVisible({
170 size,
171 threshold: config.threshold,
172 step: config.step,
173 ignoredAt: await read($, ignoredAt),
174 quiet: await read($, quiet),
175 })
176 await update($, bandSize, () => (show && size !== undefined ? size : null))
177}
178
179/** [h]: record the press, hide the band, run /session-handoff (or leave it in the prompt box). */
180async function startHandoff($: EngineInterface): Promise<void> {
181 const now = await $.clock.now()
182 await update($, pressedAt, () => now)
183 await update($, handoffDraft, () => null)
184 await update($, bandSize, () => null)
185 try {
186 await $.command.run({ command: HANDOFF_COMMAND })
187 } catch {
188 const { isFilled } = await $.prompt.fill({ text: `/${HANDOFF_COMMAND}` })
189 $.ui.toast(isFilled ? 'press Enter to run /session-handoff' : 'type /session-handoff', { timeoutMs: 10_000 })
190 }
191}
192
193/** [i]: the first hides the band until the context grows `step`; a second quiets it for the session. */
194async function ignoreBand($: EngineInterface): Promise<void> {
195 const size = await read($, bandSize)
196 if ((await read($, ignoredAt)) !== null) {
197 await update($, quiet, () => true)
198 $.ui.toast('handoff band quiet for this session', { timeoutMs: 6000 })
199 } else if (size !== null) {
200 await update($, ignoredAt, () => size)
201 }
202 await update($, bandSize, () => null)
203}
204
205/**
206 * At turn end: if an [h] press is pending, this was its handoff turn — clear
207 * only when the handoff file was written after the press. Otherwise refresh.
208 */
209async function bandTurnComplete($: EngineInterface, config: BandConfig, isInteractive: boolean): Promise<void> {
210 const pressed = await read($, pressedAt)
211 if (pressed === null) return refreshBand($, config, isInteractive)
212 await update($, pressedAt, () => null) // one check per press: never a second clear
213 const draft = await read($, handoffDraft)
214 await update($, handoffDraft, () => null)
215
216 // Saved = written after the press AND holding this session's own handoff. The mtime
217 // alone isn't proof: a parallel session can pin its own handoff as latest.md meanwhile.
218 const path = await handoffPath($, config)
219 const stat = path === undefined ? undefined : await $.fs.stat(path).catch(() => undefined)
220 const latest = stat !== undefined && stat.kind === 'file' && stat.mtimeMs > pressed && path !== undefined
221 ? await $.fs.read(path).catch(() => undefined)
222 : undefined
223 const own = draft === null ? undefined : await $.fs.read(draft).catch(() => undefined)
224 if (latest === undefined || own === undefined || own.trim() === '' || latest !== own) {
225 $.ui.toast('handoff not saved — not clearing', { timeoutMs: 15_000 })
226 return refreshBand($, config, isInteractive)
227 }
228 await update($, bandSize, () => null)
229 // /clear can't run inside the turn.complete dispatch the turn waits on: run it once that settles.
230 $.clock.after(0, () => {
231 $.command.run({ command: 'clear' }).catch(() => $.ui.toast('handoff saved — type /clear', { timeoutMs: 15_000 }))
232 })
233}
234
235/** /clear starts a new conversation with no session.start: re-arm the band for it. */
236async function resetBand($: EngineInterface): Promise<void> {
237 await update($, bandSize, () => null)
238 await update($, ignoredAt, () => null)
239 await update($, quiet, () => false)
240 await update($, pressedAt, () => null)
241 await update($, handoffDraft, () => null)
242}
243
244export const register: Register = (on, options) => {
245 const config = readConfig(options)
246 const bandConfig = readBandConfig(options)
247 // A fact about this process, not band state: session.start re-sets it on every (re)load.
248 let isInteractive = false
249
250 on('session.start', async ($, e, next) => {
251 isInteractive = e.isInteractive
252 $.ui.status(undefined) // the gauge row replaces the old one-line status
253 $.clock.every(60_000, () => void refreshStatus($, config))
254 await refreshStatus($, config)
255 return next(e)
256 })
257
258 on('prompt.submit', async ($, e, next) => {
259 if (USER_ORIGINS.has(e.origin.kind)) {
260 // Typed mid-turn: it joins the running turn's prompt rather than replacing it.
261 await update($, prompt, p => (e.turnId === undefined ? e.text : `${p}\n${e.text}`))
262 } else if (e.turnId === undefined) {
263 await update($, prompt, () => '') // a turn the user didn't start
264 }
265 return next(e)
266 })
267
268 on('turn.start', async ($, e, next) => {
269 await update($, spawns, () => 0)
270 await update($, turnBase, () => null)
271 const fiveHour = await fiveHourPercent($)
272 if (fiveHour !== undefined) await update($, turnBase, () => fiveHour)
273 await update($, heavyAlerted, () => false)
274 if (e.text === '') await update($, prompt, () => '')
275 await refreshStatus($, config)
276 return next(e)
277 })
278
279 on('turn.step', async function* ($, e, next) {
280 const result = yield* next(e)
281 if (e.agentId === undefined) {
282 const now = await $.clock.now()
283 await update($, lastResponseAt, () => now)
284 await refreshStatus($, config)
285 }
286 return result
287 })
288
289 on('session.measure', async ($, e, next) => {
290 const current = e.rateLimits.find(r => r.kind === 'five_hour')?.percentUsed
291 if (current !== undefined) {
292 const previous = await read($, lastFiveHour)
293 await update($, lastFiveHour, () => current)
294 if (config.heavy !== null) {
295 const alerts = heavyAlerts({
296 base: await read($, turnBase),
297 previous,
298 current,
299 turnAlerted: await read($, heavyAlerted),
300 ...config.heavy,
301 })
302 await update($, heavyAlerted, () => alerts.turnAlerted)
303 for (const t of alerts.toasts) $.ui.toast(`burn-guard: ${t}`, { timeoutMs: 10_000 })
304 }
305 }
306 await refreshStatus($, config)
307 return next(e)
308 })
309
310 // The engine's resolved model is known only once the subagent has started
311 // (agent.spawn's next(e) resolves after the start), too late to deny on. So
312 // the guard stays on tool.call; this records what a call naming no model
313 // actually ran on, so the next spawn of that type decides on the real model.
314 on('agent.spawn', async ($, e, next) => {
315 const result = await next(e)
316 if (result.deny === undefined && e.model === undefined && !e.fork && !(await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL'))) {
317 const model = learnedModel(result.model, e.parentModel)
318 if (model === undefined) await $.store.delete(learnedKey(e.subagentType))
319 else await $.store.set(learnedKey(e.subagentType), model)
320 }
321 return result
322 })
323
324 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
325 const session = await $.session.model()
326 const subagentType = e.subagent_type
327 const { model, source } = resolveModel({
328 subagentType,
329 inputModel: e.model,
330 frontmatter: subagentType === undefined ? undefined : await definitionModel($, subagentType),
331 learned: await learned($, subagentType ?? 'general-purpose'),
332 defaultSubagent: await $.env.get('CLAUDE_CODE_SUBAGENT_MODEL'),
333 session,
334 })
335 const tier = tierOf(model)
336 const label = `${subagentType ?? 'general-purpose'} on ${model} (from ${source})`
337
338 if (tier === 'cheap') return next(e)
339
340 if (tier === 'fable' && config.fableManualOnly && tierOf(session) !== 'fable' && !requestsFable(await read($, prompt))) {
341 return deny(
342 $,
343 `Fable-tier spawn blocked: ${label}. Fable is manual-only. Ask the user first; spawn it only after they explicitly ask for Fable in their reply.`,
344 `Fable spawn ${label}`,
345 )
346 }
347
348 const count = await update($, spawns, n => n + 1)
349 if (count > EXPENSIVE_PER_TURN) {
350 await update($, spawns, n => n - 1)
351 await refreshStatus($, config)
352 return deny(
353 $,
354 `expensive-tier spawn #${count} this turn blocked: ${label}. The limit is ${EXPENSIVE_PER_TURN} per turn. Get the user's confirmation before spawning more, or pin the agent to sonnet/haiku.`,
355 `expensive spawn #${count} ${label}`,
356 )
357 }
358 await refreshStatus($, config)
359 return next(e)
360 }).catch(($, e, next) =>
361 // Fail closed: a guard that crashed before deciding must not wave the spawn through.
362 next.called
363 ? next(e)
364 : deny($, `the spawn guard failed (${next.error.kind}: ${next.error.message ?? "no message"}) before it could check this spawn. Tell the user; don't retry.`, 'guard error'),
365 )
366
367 // While an [h] handoff runs, note the dated file this session's own Write saves.
368 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
369 const result = await next(e)
370 const isSaved = result.deny === undefined && result.isError !== true
371 if (isSaved && e.agentId === undefined && isHandoffDraft(e.file_path) && (await read($, pressedAt)) !== null) {
372 await update($, handoffDraft, () => e.file_path)
373 }
374 return result
375 })
376
377 on('turn.complete', async ($, e, next) => {
378 const result = await next(e)
379 if (e.agentId === undefined) await bandTurnComplete($, bandConfig, isInteractive)
380 return result
381 })
382
383 on('session.end', async ($, e, next) => {
384 if (e.reason === 'clear') await resetBand($)
385 return next(e)
386 })
387
388 // Above the prompt: the usage gauge (always, even mid-turn) and, past a context size at
389 // turn end, the handoff band beneath it. Headless never raises AbovePrompt and
390 // refreshBand never arms the band there. The band has two buttons only — no compaction path.
391 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
392 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
393 const { Box, Button, Text } = $.ui.resolve(e)
394 // The band is one site shared by every mod: stack our row on what the mods beneath draw, never replace it.
395 const withBelow = async (mine: RenderChildren) => <Box flexDirection="column">{mine}{await next(e)}</Box>
396
397 const snap = await read($, readings)
398 const base = await read($, turnBase)
399 const cacheLeft = await read($, cacheLeftMin)
400 const now = await $.clock.now()
401 const gauge = gaugeFit(
402 gaugeSegments({
403 fiveHour: snap?.fiveHour ?? undefined,
404 resetMs: snap?.fiveHourResetsAt == null ? undefined : Date.parse(snap.fiveHourResetsAt) - now,
405 sevenDay: snap?.sevenDay ?? undefined,
406 turnBurn: base === null || snap?.fiveHour == null ? null : turnDelta(base, snap.fiveHour),
407 heavyTurnPct: config.heavy?.turnPct ?? 5,
408 cacheLeft,
409 contextTokens: snap?.contextTokens ?? undefined,
410 spawns: await read($, spawns),
411 }),
412 e.props.bodyColumns,
413 )
414
415 const size = e.props.isWorking ? null : await read($, bandSize)
416 if (gauge.length === 0 && size === null) return next(e)
417
418 const gaugeRow =
419 gauge.length === 0 ? null : (
420 <Box>
421 {gauge.flatMap((seg, i) => [
422 i === 0 || seg.sep === '' ? null : (
423 <Text key={`s${i}`} dimColor>
424 {seg.sep}
425 </Text>
426 ),
427 <Text key={`t${i}`} wrap="truncate" {...TONE[seg.tone]}>
428 {seg.text}
429 </Text>,
430 ])}
431 </Box>
432 )
433 if (size === null) return withBelow(gaugeRow)
434
435 const layout = bandLayout({ columns: e.props.bodyColumns, note: bandCacheText(cacheLeft) })
436 return withBelow(
437 <Box flexDirection="column">
438 {gaugeRow}
439 <Box gap={3}>
440 {layout.text === '' ? null : (
441 <Text key="band-note" color="yellow" wrap="truncate">
442 {layout.text}
443 </Text>
444 )}
445 <Button plain key="handoff" hotkey="h" label={HANDOFF_LABEL} onPress={() => startHandoff($)} />
446 {layout.hasIgnore ? <Button plain dimColor key="ignore" hotkey="i" label={IGNORE_LABEL} onPress={() => ignoreBand($)} /> : null}
447 </Box>
448 </Box>
449 )
450 })
451}
452hooks/logic.ts 277 lines1import type { BurnGuardTier } from '../types'
2
3export const EXPENSIVE_PER_TURN = 2
4export const CACHE_TTL_MS = 60 * 60 * 1000
5
6/**
7 * Tier of a model name, alias or full ID ("fable", "claude-opus-5-5", "Opus 5.5").
8 * An unrecognised name counts as expensive: over-counting costs a confirm,
9 * under-counting costs the 5h window.
10 */
11export function tierOf(model: string): BurnGuardTier {
12 const m = model.toLowerCase()
13 if (m.includes('fable')) return 'fable'
14 if (m.includes('sonnet') || m.includes('haiku')) return 'cheap'
15 return 'expensive'
16}
17
18/** The `model:` line of an agent file's frontmatter, or undefined. */
19export function frontmatterModel(agentFile: string): string | undefined {
20 const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(agentFile)?.[1]
21 const model = block === undefined ? undefined : /^model:\s*['"]?([^'"\s]+)/m.exec(block)?.[1]
22 return model === undefined || model === 'inherit' ? undefined : model
23}
24
25export type ModelSource = 'input' | 'frontmatter' | 'learned' | 'default-subagent' | 'session'
26
27/**
28 * The model an Agent call will run on, in the Agent tool's own precedence:
29 * explicit `model` input, the definition's frontmatter (or, for a built-in with
30 * no file, the model an earlier spawn of it resolved to), the configured
31 * default subagent model, then the session's. A `fork` always inherits the session's.
32 */
33export function resolveModel(args: {
34 subagentType: string | undefined
35 inputModel: string | undefined
36 frontmatter: string | undefined
37 learned?: string
38 defaultSubagent: string | undefined
39 session: string
40}): { model: string; source: ModelSource } {
41 if (args.subagentType === 'fork') return { model: args.session, source: 'session' }
42 if (args.inputModel) return { model: args.inputModel, source: 'input' }
43 if (args.frontmatter) return { model: args.frontmatter, source: 'frontmatter' }
44 if (args.learned) return { model: args.learned, source: 'learned' }
45 if (args.defaultSubagent) return { model: args.defaultSubagent, source: 'default-subagent' }
46 return { model: args.session, source: 'session' }
47}
48
49const FABLE_REQUEST =
50 /\b(?:ask|use|have|get|let|run|spawn|call|consult|involve|send|by|via|loop\s+in|bring\s+in|check\s+with|escalate\s+to)\s+(?:the\s+|it\s+to\s+)?fable\b|\bfable[-\s]advisor\b|\bfable[,:]?\s+(?:to\s+|should\s+|can\s+|could\s+)?(?:look|review|check|audit|investigate|examine|weigh|analy[sz]e|go\s+over)\b/gi
51const NEGATED = /\b(?:don'?t|do\s+not|never|not|no|without)\s+(?:\w+\s+){0,2}$/i
52
53/**
54 * Whether the user's prompt this turn actually asks for Fable (the manual-only
55 * unlock): a verb aimed at Fable ("ask/use/have/get/let/run fable", "run it by fable"),
56 * Fable as the one doing the work ("fable review this"), or "fable advisor".
57 * A passing mention ("is fable cheaper?") doesn't count.
58 * A request negated within two words before it ("don't use fable") doesn't count.
59 */
60export function requestsFable(prompt: string): boolean {
61 for (const m of prompt.matchAll(FABLE_REQUEST)) {
62 if (!NEGATED.test(prompt.slice(0, m.index))) return true
63 }
64 return false
65}
66
67/**
68 * The model a spawn taught us its agent type runs on when the call names none:
69 * the resolved model when it differs from the parent's (the definition pins
70 * one), undefined when it simply inherited.
71 */
72export const learnedModel = (resolved: string, parent: string): string | undefined =>
73 resolved === parent ? undefined : resolved
74
75/** The 5h window's growth since the turn began; a reading below the base means it reset mid-turn. */
76export const turnDelta = (base: number, current: number): number => (current >= base ? current - base : current)
77
78// ── Gauge row ───────────────────────────────────────────────────────────────
79
80export type Tone = 'plain' | 'dim' | 'info' | 'meta' | 'ok' | 'warn' | 'bad'
81/** One piece of the gauge: `sep` is what joins it to the piece before; a higher `rank` is dropped first when narrow. */
82export type GaugeSegment = { text: string; tone: Tone; rank: number; sep: string }
83
84export const BAR_CELLS = 8
85export const WARN_WINDOW_PCT = 60
86export const BAD_WINDOW_PCT = 80
87
88/** `▰▰▰▱▱▱▱▱`: a nonzero reading always fills at least one cell, a full-scale one fills all. */
89export function windowBar(pct: number, cells = BAR_CELLS): string {
90 const raw = Math.round((pct / 100) * cells)
91 const filled = Math.min(cells, Math.max(pct > 0 ? 1 : 0, raw))
92 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
93}
94
95export const windowTone = (pct: number): Tone => (pct >= BAD_WINDOW_PCT ? 'bad' : pct >= WARN_WINDOW_PCT ? 'warn' : 'ok')
96
97/** `45m`, `2h10m`, `1d4h`; `now` once the reset is due. */
98export function formatReset(ms: number): string {
99 const minutes = Math.ceil(ms / 60_000)
100 if (minutes <= 0) return 'now'
101 if (minutes < 60) return `${minutes}m`
102 const hours = Math.floor(minutes / 60)
103 if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
104 return `${Math.floor(hours / 24)}d${hours % 24}h`
105}
106
107const roundTenth = (n: number): number => Math.round(n * 10) / 10
108
109/**
110 * The gauge's pieces in display order: the 5h window (bar, %, time to reset),
111 * the 7d window beside it, this turn's burn once it is visible, cache warmth,
112 * context size, and the expensive-spawn count only once one is used.
113 */
114export function gaugeSegments(args: {
115 fiveHour: number | undefined
116 /** Milliseconds until the 5h window resets. */
117 resetMs: number | undefined
118 sevenDay: number | undefined
119 /** Null when the turn's base reading is unknown. */
120 turnBurn: number | null
121 heavyTurnPct: number
122 /** Whole minutes of cache left; null before the first response. */
123 cacheLeft: number | null
124 contextTokens: number | undefined
125 spawns: number
126}): GaugeSegment[] {
127 const out: GaugeSegment[] = []
128 if (args.fiveHour !== undefined) {
129 out.push({ text: `5h ${windowBar(args.fiveHour)} ${Math.round(args.fiveHour)}%`, tone: windowTone(args.fiveHour), rank: 0, sep: '' })
130 if (args.resetMs !== undefined) out.push({ text: `↻ ${formatReset(args.resetMs)}`, tone: 'info', rank: 4, sep: ' ' })
131 }
132 if (args.sevenDay !== undefined) {
133 out.push({ text: `7d ${Math.round(args.sevenDay)}%`, tone: windowTone(args.sevenDay), rank: 5, sep: ' · ' })
134 }
135 if (args.turnBurn !== null && args.turnBurn >= 0.1) {
136 out.push({ text: `+${roundTenth(args.turnBurn)}% turn`, tone: args.turnBurn > args.heavyTurnPct ? 'warn' : 'meta', rank: 2, sep: ' · ' })
137 }
138 if (args.cacheLeft !== null) {
139 const cold = args.cacheLeft <= 0
140 out.push({
141 text: cold ? 'cache cold' : `cache ${args.cacheLeft}m`,
142 tone: cold ? 'bad' : args.cacheLeft <= HANDOFF_NUDGE_MIN ? 'warn' : 'meta',
143 rank: 3,
144 sep: ' · ',
145 })
146 }
147 if (args.contextTokens !== undefined) {
148 out.push({ text: `ctx ${Math.round(args.contextTokens / 1000)}K`, tone: 'meta', rank: 6, sep: ' · ' })
149 }
150 if (args.spawns > 0) {
151 out.push({ text: `exp ${args.spawns}/${EXPENSIVE_PER_TURN}`, tone: args.spawns >= EXPENSIVE_PER_TURN ? 'bad' : 'warn', rank: 1, sep: ' · ' })
152 }
153 return out
154}
155
156/** What fits in `columns`: the highest-rank piece goes first, repeatedly; the 5h bar is never dropped. */
157export function gaugeFit(segments: GaugeSegment[], columns: number): GaugeSegment[] {
158 const width = (segs: GaugeSegment[]) => segs.reduce((n, seg, i) => n + seg.text.length + (i === 0 ? 0 : seg.sep.length), 0)
159 let kept = segments
160 while (kept.length > 1 && width(kept) > columns) {
161 const drop = kept.reduce((worst, seg) => (seg.rank > worst.rank ? seg : worst))
162 kept = kept.filter(seg => seg !== drop)
163 }
164 return kept
165}
166
167/**
168 * The once-per-countdown nudge: its text when the cache has at most `leadMs`
169 * left and this countdown (keyed by `lastResponseAt`) hasn't been nudged yet.
170 */
171export function cacheNudge(args: {
172 now: number
173 lastResponseAt: number | null
174 nudgedFor: number | null
175 leadMs: number
176}): string | undefined {
177 if (args.lastResponseAt === null || args.nudgedFor === args.lastResponseAt) return undefined
178 const left = CACHE_TTL_MS - (args.now - args.lastResponseAt)
179 if (left <= 0 || left > args.leadMs) return undefined
180 return `cache expires in ${Math.ceil(left / 60_000)}m — send now or let it go`
181}
182
183/**
184 * Heavy-turn alerts for one 5h reading: the turn's burn passing `turnPct`
185 * (once per turn) and the window crossing `windowPct` upward.
186 */
187export function heavyAlerts(args: {
188 base: number | null
189 previous: number | null
190 current: number
191 turnAlerted: boolean
192 turnPct: number
193 windowPct: number
194}): { toasts: string[]; turnAlerted: boolean } {
195 const toasts: string[] = []
196 let turnAlerted = args.turnAlerted
197 if (!turnAlerted && args.base !== null) {
198 const delta = turnDelta(args.base, args.current)
199 if (delta > args.turnPct) {
200 toasts.push(`heavy turn: +${Math.round(delta * 10) / 10}% of the 5h window so far`)
201 turnAlerted = true
202 }
203 }
204 if (args.previous !== null && args.previous < args.windowPct && args.current >= args.windowPct) {
205 toasts.push(`5h window at ${args.current}% (crossed ${args.windowPct}%)`)
206 }
207 return { toasts, turnAlerted }
208}
209
210// ── Handoff band ────────────────────────────────────────────────────────────
211
212/** At or under this many minutes of cache left, the band urges a handoff while the re-read is cheap. */
213export const HANDOFF_NUDGE_MIN = 10
214
215/** Whole minutes of prompt cache left (0 once cold); null before the first response. */
216export function cacheMinutesLeft(now: number, lastResponseAt: number | null): number | null {
217 if (lastResponseAt === null) return null
218 return Math.max(0, Math.ceil((CACHE_TTL_MS - (now - lastResponseAt)) / 60_000))
219}
220
221/**
222 * Whether the band shows for a context of `size` tokens at turn end: past the
223 * threshold, not quieted, and — after one [i] — grown `step` past the ignored size.
224 */
225export function bandVisible(args: {
226 size: number | undefined
227 threshold: number
228 step: number
229 ignoredAt: number | null
230 quiet: boolean
231}): boolean {
232 if (args.quiet || args.size === undefined || args.size < args.threshold) return false
233 return args.ignoredAt === null || args.size >= args.ignoredAt + args.step
234}
235
236/**
237 * The band's one note, only when the cache argues for acting now: the hand-off-now
238 * nudge or the cold warning. A healthy countdown has none: the gauge row shows it.
239 */
240export function bandCacheText(minutesLeft: number | null): string | undefined {
241 if (minutesLeft === null) return undefined
242 if (minutesLeft <= 0) return 'cache cold — handoff will cost full price'
243 if (minutesLeft <= HANDOFF_NUDGE_MIN) return `cache cold in ${minutesLeft}m — hand off now while it's cheap`
244 return undefined
245}
246
247export const HANDOFF_LABEL = 'handoff + clear'
248export const IGNORE_LABEL = 'ignore'
249// A plain terminal Button with a hotkey draws `h: label`; the band's Box puts BAND_GAP cells between its pieces.
250const BAND_GAP = 3
251const buttonCells = (label: string) => label.length + 3 + BAND_GAP
252
253/**
254 * What fits in `columns` beside the buttons: the note and both buttons, then
255 * without the note, then without [i]. [h] is never dropped.
256 */
257export function bandLayout(args: { columns: number; note: string | undefined }): {
258 text: string
259 hasIgnore: boolean
260} {
261 const candidates = [
262 { text: args.note ?? '', hasIgnore: true },
263 { text: '', hasIgnore: true },
264 { text: '', hasIgnore: false },
265 ]
266 const fits = (c: { text: string; hasIgnore: boolean }) =>
267 (c.text === '' ? 0 : c.text.length + BAND_GAP) + buttonCells(HANDOFF_LABEL) - BAND_GAP + (c.hasIgnore ? buttonCells(IGNORE_LABEL) : 0) <= args.columns
268 return candidates.find(fits) ?? { text: '', hasIgnore: false }
269}
270
271/**
272 * Whether a Write is /session-handoff saving its dated file
273 * (`~/.claude/handoffs/<date>-<HHMM>[-min].md`), not the `latest.md` pin.
274 */
275export const isHandoffDraft = (path: string): boolean =>
276 /\/\.claude\/handoffs\/[^/]+\.md$/.test(path) && !path.endsWith('/latest.md')
277types/index.d.ts 46 lines1// Tiers: expensive = aliases `fable` + `opus`; `fable` alone is manual-only (fableManualOnly).
2export type BurnGuardTier = 'fable' | 'expensive' | 'cheap'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'burn-guard': {
7 /** The user's own prompt text for the current turn ('' when the turn wasn't theirs). */
8 prompt: string
9 /** Expensive-tier spawns allowed so far this turn. */
10 spawns: number
11 /** 5h rate-limit % at turn start; null until a reading exists. */
12 turnBase: number | null
13 /** Epoch ms of the last main-thread model response; null before the first. */
14 lastResponseAt: number | null
15 /** The `lastResponseAt` whose countdown was already nudged; null when none. */
16 nudgedFor: number | null
17 /** The last 5h % a measurement reported; null until one did. */
18 lastFiveHour: number | null
19 /** Whether this turn already raised the heavy-turn toast. */
20 heavyAlerted: boolean
21 /** Usage the gauge row draws, refreshed ~once a minute and on turn events; null before the first read. */
22 readings: {
23 /** 5h window % used; null off a subscription. */
24 fiveHour: number | null
25 /** ISO time the 5h window resets. */
26 fiveHourResetsAt: string | null
27 sevenDay: number | null
28 /** Input tokens the last response was answered over. */
29 contextTokens: number | null
30 } | null
31 /** Whole minutes of prompt cache left (0 = cold), ticked ~once a minute; null before the first response. */
32 cacheLeftMin: number | null
33 /** Context size the handoff band shows, set at turn end; null while the band is hidden. */
34 bandSize: number | null
35 /** Context size at the first [i] press this session; null while not ignored. */
36 bandIgnoredAt: number | null
37 /** Set by a second [i]: the band stays hidden for the rest of the session. */
38 bandQuiet: boolean
39 /** Epoch ms of the [h] press whose handoff turn hasn't completed yet; null otherwise. */
40 handoffPressedAt: number | null
41 /** The dated handoff file this session's own Write created after the [h] press; null until then. */
42 handoffDraft: string | null
43 }
44 }
45}
46