SLOPSHOPPER

burn-guard

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…

newbandguardtoaststatusprompt
v0.4.0MITupdated 2026-10-02ozlar34/claude-code-mods/burn-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · burn-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 5h ▰▰▱▱▱▱▱▱ 31% ↻ NaNdNaNh · ctx 97K ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ▰▰▱▱▱▱▱▱ 31% ↻ NaNdNaNh · ctx 97K ⟨Claude Code's own drawing⟩
README

burn-guard

Guards the 5-hour usage window. Three parts:

  1. Usage gauge above the prompt: a colour-coded bar for the 5h window, when it resets, what the last turn burned, how warm the prompt cache still is, and the context size the next request re-sends. The expensive-spawn count joins it once a turn uses one.
  2. Spawn guard on the Agent tool:
  3. Sonnet/Haiku subagents always pass.
  4. At most 2 expensive-tier spawns (Opus, Fable, or any model it doesn't recognise) per turn; the third is denied with a reason telling the model to ask you first or pin the agent to sonnet/haiku. Change the limit with EXPENSIVE_PER_TURN in hooks/logic.ts.
  5. Fable is manual-only (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.
  6. The model of each spawn is resolved the way Claude Code does it (explicit 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.
  7. If the guard itself crashes, the spawn is denied (fail closed).
  8. Handoff band at turn end, once the context reaches 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.

Files

  • hooks/logic.ts: pure parts (tier mapping, Fable-request wording, gauge layout, band rules)
  • hooks/register.tsx: hooks, gauge and band rendering, the spawn guard
  • tests/: claude plugin test <this folder>
Source 3 files
hooks/register.tsx 452 lines
1import { 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}
452
hooks/logic.ts 277 lines
1import 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')
277
types/index.d.ts 46 lines
1// 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