SLOPSHOPPER

budget-guard

Stops a session before it runs past a budget. Three figures are guarded, each with a limit you set: the session cost in dollars, the 5-hour plan window and the…

newguardcommandtoaststatusprompt
★ 4v0.1.0MITupdated 2026-09-15Arunjay4213/claude-mods/plugins/budget-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · budget-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 › /guard ⎿ budget-guard: on, in block mode. It warns at 80% of a limit. ⎿ budget-guard: ⎿ budget-guard: session cost $0.42, no limit not guarded ⎿ budget-guard: 5-hour window 31% of 90% ok ⎿ budget-guard: 7-day window not reported by the plan not guarded ⎿ budget-guard: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

budget-guard

A spending guard for Claude Code, built as a mod (function hooks).

It watches three figures, each with a limit you set, and stops work before the limit turns into a surprise:

  • the session cost in dollars,
  • how much of the 5-hour plan window is used,
  • how much of the 7-day plan window is used.

It stays invisible while everything is fine. No status line, no toasts, nothing.

What it does

At 80% of a limit it toasts once and pins a line under the prompt:

budget-guard: cost $4.10/$5.00 82% · 5h 71/90% · 7d 15/95%

The line stays while any figure is at warn or over, and disappears the moment everything is back under the warn mark. The toast is not repeated until the level changes.

Past a limit, in block mode (the default), two things happen.

The next tool call is refused, including a subagent's, because subagents spend too, and the turn is stopped, because a model that is refused one tool tries another and every try is a billed API call:

● Bash(echo c)
  ⎿  Error: budget-guard: session cost $0.67 passed the $0.66 limit, so the turn was stopped.
     /guard override allows the next turn; /guard cost 2 raises the limit.

And sending a new prompt asks first:

 ☐ Budget
Session cost is $0.59, past its $0.36 limit. Send this prompt anyway?
❯ 1. Send anyway
  2. Do not send

"Send anyway" sets an override for that one turn. "Do not send" drops the prompt and says why:

● Prompt dropped by a hook: budget-guard: session cost $0.59 passed the $0.36 limit.
  The prompt was not sent. /guard override sends the next one; /guard cost 1 raises the limit.

Past a limit, in warn mode, nothing is ever refused. The guard toasts once a turn and keeps the status line pinned, and that is all.

Install

claude plugin marketplace add Arunjay4213/claude-mods
claude plugin install budget-guard@claude-mods

Mods are early access, so the module only loads when function hooks are switched on. Add this to ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Or set it for one run: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

Screenshot

The refused tool call, the /guard state, and the pinned line

/guard

Run /guard on its own to see where everything stands:

budget-guard: on, in block mode. It warns at 80% of a limit.

  session cost   $0.31, no limit  not guarded
  5-hour window  14% of 90%       ok
  7-day window   15% of 95%       ok

Change it:
  /guard cost 5     dollar limit for this session (0 turns it off)
  /guard 5h 90      percent of the 5-hour plan window
  /guard 7d 95      percent of the 7-day plan window
  /guard warn       only say so, never refuse
  /guard block      refuse tool calls once a limit is passed
  /guard override   allow the next turn even if a figure is over
  /guard off        turn the guard off    (/guard on turns it back)

/guard override lasts exactly one turn. A subagent's turn finishing inside your turn does not use it up.

Settings

The six settings are the plugin's own userConfig fields, so /config shows them too.

FieldDefaultWhat it is
costLimitUsd0Dollars this session may spend. 0 turns the cost guard off.
fiveHourLimitPercent90Percent of the 5-hour plan window allowed.
sevenDayLimitPercent95Percent of the 7-day plan window allowed.
warnAtPercent80How far along a limit counts as a warning.
modeblockblock refuses work past a limit, warn only says so.
enabledtrueTurn the whole guard off without uninstalling it.

Changing one with /guard writes it through $.config.set, which lands in ~/.claude/settings.json under pluginConfigs["budget-guard"].options, so it survives a restart. Claude Code reloads the module when its options change, which is why /guard cost 5 is followed by a short "options changed - reloaded" notice.

If a host ever refuses a plugin writing its own userConfig row, the guard falls back to $.store for the same six fields and /guard says "Kept in the plugin store" instead of "Kept in the plugin settings". On the Claude Code build this was written against (2.1.272) the $.config.set path works, so the store fallback is the unusual case.

Where the numbers come from

Everything is read with $.session.usage() and no argument, which is the free form: it reports the figures the last API response already carried, and computes nothing.

  • cost.usd is the same dollar figure /cost and the status line show.
  • rateLimits[] carries five_hour and seven_day with a percentUsed, rounded here with Math.round.

The reading is taken at session.start, after every turn, and inside tool.call before the guard decides, because cost keeps moving while a turn runs.

The tool.call read is measured once per session and the figure is written to the debug log:

[budget-guard] $.ui.log: session.usage() took 5 ms in tool.call - under 30 ms, so every check reads live

If the read comes back slower than 30 ms the guard caches it for 3 seconds instead of reading on every tool call. Both paths were seen in testing: 5 ms on a cold worker and 62 ms on a reloaded one, where the cache switched itself on.

Limitations

A hook that throws or times out is skipped by the engine. The hooks beneath it and Claude Code's own logic run in its place. That makes this a guard rail, not a lock: it is here to stop an expensive session from drifting past a budget you set, not to enforce a spending policy on someone who does not want it. Anyone can turn it off with /guard off.

The deny is advisory in one specific sense. It refuses the next tool call and then stops the turn. API calls that have already started still finish and are still billed, so the cost can land a little past the limit before anything is refused. In practice the overshoot is one model step.

API-key sessions have no window guards. rateLimits is empty without a subscription, so the 5-hour and 7-day figures are never reported and those two guards stay inactive. /guard says so plainly, and the cost guard still works.

Cost settles between steps, not continuously. The engine's cost ledger updates as each API response is accounted for, so a short single-step turn can end past the limit without any tool call having been refused. The next turn is then caught at prompt.submit.

$.ui.ask needs someone to ask. In a -p or SDK run there is no one at the keyboard, so a prompt submitted while a figure is over is dropped with the reason rather than questioned. /guard override or a higher limit is the way through.

Source 2 files
hooks/register.tsx 375 lines
1/* @jsx h */
2import type { EngineInterface, PluginOptions, Register, SessionRateLimit } from 'claude-code'
3
4import {
5  actionOf,
6  DEFAULTS,
7  denyReasonOf,
8  dropReasonOf,
9  figuresOf,
10  firstOver,
11  overTextOf,
12  settingsOf,
13  stateTextOf,
14  statusOf,
15  usd,
16  warnModeTextOf,
17  warnTextOf,
18  WINDOWS,
19  type Field,
20  type Figure,
21  type FigureKey,
22  type Level,
23  type Settings,
24} from './guard'
25
26// budget-guard: three figures with a limit each - the session's cost in
27// dollars, and how much of the 5-hour and 7-day plan windows is used.
28//
29// It says nothing while everything is fine. Once a figure reaches the warn
30// mark it pins a status line and toasts once; once a figure passes its limit
31// it refuses tool calls and asks before sending a new prompt, unless the mode
32// is "warn" or the user set an override for the turn.
33//
34// The readings come from `$.session.usage()` with no argument, which is the
35// free form: it reports what the last API response already carried.
36
37// The engine draws the plugin's name in front of every toast, status line,
38// `$.ui.log` line and command output, so none of those texts names itself. A
39// deny and a drop reach the model and the transcript unnamed, so those two do.
40const COMMAND = 'guard'
41const STORE_KEY = 'settings'
42/** A usage read slower than this is worth caching instead of repeating. */
43const SLOW_READ_MS = 30
44/** How long a cached reading stands, when caching is on. */
45const CACHE_MS = 3_000
46/** `$.ui.ask` label the user picks to send the prompt anyway. */
47const SEND_ANYWAY = 'Send anyway'
48const DO_NOT_SEND = 'Do not send'
49
50let settings: Settings = DEFAULTS
51let costUsd: number | null = null
52let limits: SessionRateLimit[] = []
53/** The level each figure was last announced at, so a toast is not repeated. */
54let announced: Partial<Record<FigureKey, Level>> = {}
55/** Set by `/guard override` or by answering the ask: one turn runs anyway. */
56let isOverridden = false
57/** The fields held in `$.store` because `$.config.set` would not take them. */
58let storedFields: Record<string, unknown> = {}
59let isStoreUsed = false
60/** How long the last usage read took, and when it was taken. */
61let readMs = 0
62let readAtMs = 0
63/** The running main turn, so a refusal can end it instead of leaving the model to retry. */
64let turnId: string | null = null
65/** True from the main turn's start to its end; a turn.start inside that span is a subagent's. */
66let isMainTurnOpen = false
67/** The turn already being stopped, so several refusals in one turn stop it once. */
68let stoppingTurnId: string | null = null
69
70/** Runs `work`, and on a failure keeps whatever the guard already had. */
71const quietly = async <T,>(work: () => Promise<T>): Promise<T | null> => {
72  try {
73    return await work()
74  } catch {
75    return null
76  }
77}
78
79/** `session cost` -> `Session cost`, for a line that starts with a label. */
80const sentenceCase = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1)
81
82/** Shows `text`, or does nothing where the toast will not draw. */
83function toast($: EngineInterface, text: string): void {
84  try {
85    $.ui.toast(text)
86  } catch {
87    // a toast is a courtesy, never a reason to fail a hook
88  }
89}
90
91/**
92 * Takes a reading. `mayCache` is honoured only once a read has measured slower
93 * than 30 ms, so a fast host always sees the live figures.
94 */
95async function sample($: EngineInterface, mayCache = false): Promise<void> {
96  if (mayCache && readMs > SLOW_READ_MS && Date.now() - readAtMs < CACHE_MS) return
97
98  const startedAt = Date.now()
99  const usage = await quietly(() => $.session.usage())
100  if (usage === null) return
101
102  readMs = Date.now() - startedAt
103  readAtMs = Date.now()
104  costUsd = usage.cost?.usd ?? null
105  limits = usage.rateLimits ?? []
106}
107
108/** The figures as they read now. */
109const figures = (): Figure[] => figuresOf(costUsd, limits, settings)
110
111/** Pins the status line while a figure needs watching, and clears it after. */
112function paint($: EngineInterface, shown: readonly Figure[]): void {
113  try {
114    $.ui.status(settings.enabled ? statusOf(shown) : undefined)
115  } catch {
116    // the next sample tries again
117  }
118}
119
120/**
121 * Toasts each figure that has just risen to a new level, and in warn mode says
122 * once a turn that a figure is over, since nothing is refused there.
123 */
124function announce($: EngineInterface, shown: readonly Figure[], isTurnEnd: boolean): void {
125  for (const figure of shown) {
126    const before = announced[figure.key] ?? 'ok'
127    if (figure.level === before) continue
128    announced[figure.key] = figure.level
129    if (figure.level === 'warn') toast($, warnTextOf(figure))
130    else if (figure.level === 'over' && settings.mode === 'block')
131      toast($, overTextOf(figure, '/guard override allows the next turn;'))
132  }
133
134  // a figure that fell back to ok may rise again and should toast again
135  for (const key of Object.keys(announced) as FigureKey[]) {
136    if (!shown.some(figure => figure.key === key)) delete announced[key]
137  }
138
139  if (!isTurnEnd || settings.mode !== 'warn') return
140  const over = firstOver(shown)
141  if (over !== undefined) toast($, warnModeTextOf(over))
142}
143
144/** Samples, repaints and announces: everything one observation point does. */
145async function refresh($: EngineInterface, isTurnEnd: boolean): Promise<void> {
146  await sample($)
147  const shown = figures()
148  paint($, shown)
149  if (settings.enabled) announce($, shown, isTurnEnd)
150}
151
152/** Reads the settings: the manifest's defaults, then anything the store holds. */
153async function load($: EngineInterface, options: PluginOptions): Promise<void> {
154  settings = settingsOf(DEFAULTS, options)
155  const stored = await quietly(() => $.store.get(STORE_KEY))
156  if (typeof stored !== 'object' || stored === null) return
157  storedFields = { ...(stored as Record<string, unknown>) }
158  isStoreUsed = Object.keys(storedFields).length > 0
159  settings = settingsOf(settings, storedFields)
160}
161
162/**
163 * Writes one field so it survives a restart. The plugin's own `userConfig` row
164 * is the right home; where the engine will not let a plugin write its own row,
165 * `$.store` holds it instead and `/guard` says which was used.
166 */
167type Saved = 'settings' | 'store' | 'nowhere'
168
169async function save(
170  $: EngineInterface,
171  field: Field,
172  value: string | number | boolean,
173): Promise<Saved> {
174  const written = await quietly(() =>
175    $.config.set({ key: `budget-guard.${field}`, value }),
176  )
177
178  if (written !== null && written.deny === undefined) {
179    if (field in storedFields) {
180      delete storedFields[field]
181      await quietly(() => $.store.set(STORE_KEY, storedFields))
182    }
183    return 'settings'
184  }
185
186  storedFields[field] = value
187  isStoreUsed = true
188  return (await quietly(() => $.store.set(STORE_KEY, storedFields))) === null ? 'nowhere' : 'store'
189}
190
191/** What `/guard` prints after a change, saying where the change was kept. */
192const savedNote = (saved: Saved): string =>
193  saved === 'settings'
194    ? ' Kept in the plugin settings, so it survives a restart.'
195    : saved === 'store'
196      ? ' Kept in the plugin store, so it survives a restart.'
197      : ' It could not be saved, so it lasts only for this session.'
198
199export const register: Register = (on, options) => {
200  on('session.start', async ($, e, next) => {
201    const result = await next(e)
202
203    await load($, options)
204
205    await quietly(() =>
206      $.command.register({
207        name: COMMAND,
208        description: 'Budget guard: session cost and plan window limits (budget-guard)',
209        argumentHint: '[cost 5 | 5h 90 | 7d 95 | warn | block | override | off | on]',
210      }),
211    )
212
213    await refresh($, false)
214
215    return result
216  })
217
218  on('turn.complete', async ($, e, next) => {
219    const result = await next(e)
220
221    // the override covers one turn of the main loop; a subagent's turn ending
222    // inside it must not take the override away from the turn that set it
223    if (e.agentId === undefined) {
224      isOverridden = false
225      isMainTurnOpen = false
226    }
227
228    await refresh($, true)
229
230    return result
231  })
232
233  on('tool.call', async ($, e, next) => {
234    // the dialog `$.ui.ask` opens is itself a tool call of this plugin's, and
235    // refusing it would make the question unanswerable
236    if (!settings.enabled || settings.mode !== 'block' || e.tool === 'AskUserQuestion') {
237      return next(e)
238    }
239
240    // cost moves inside a turn as steps finish, so the figure is read here and
241    // not only between turns
242    await sample($, true)
243
244    const shown = figures()
245    paint($, shown)
246    announce($, shown, false)
247
248    if (isOverridden) return next(e)
249
250    const over = firstOver(shown)
251    if (over === undefined) return next(e)
252
253    // Refusing one call is not enough: the model answers a refusal by trying
254    // another tool, and every try is an API call that costs money, so the turn
255    // is ended too. The stop waits a moment so the refusal, with its reason, is
256    // recorded in the transcript first; stopped at once, the transcript would
257    // show only "Interrupted" where the refusal belongs.
258    const reason = denyReasonOf(over)
259    const running = turnId
260    if (running !== null && stoppingTurnId !== running) {
261      stoppingTurnId = running
262      try {
263        $.clock.after(250, () => {
264          void quietly(() => $.turn.abort({ turnId: running }))
265        })
266      } catch {
267        void quietly(() => $.turn.abort({ turnId: running }))
268      }
269    }
270
271    return { deny: reason }
272  })
273
274  on('turn.start', async ($, e, next) => {
275    // turn.start names no agent, so the first start after the main turn ended
276    // is the main turn's; any start before it ends again is a subagent's
277    if (!isMainTurnOpen) {
278      isMainTurnOpen = true
279      turnId = e.turnId
280    }
281    return next(e)
282  })
283
284  on('prompt.submit', async ($, e, next) => {
285    if (!settings.enabled || settings.mode !== 'block') return next(e)
286    // a slash command must always get through, or /guard override could not be typed
287    if (e.text.trimStart().startsWith('/')) return next(e)
288
289    await sample($, true)
290    const shown = figures()
291    paint($, shown)
292
293    if (isOverridden) return next(e)
294
295    const over = firstOver(shown)
296    if (over === undefined) return next(e)
297
298    const answer = await quietly(() =>
299      $.ui.ask(`${sentenceCase(over.label)} is ${over.valueText}, past its ${over.limitText} limit. Send this prompt anyway?`, {
300        options: [SEND_ANYWAY, DO_NOT_SEND],
301        header: 'Budget',
302      }),
303    )
304
305    if (answer === SEND_ANYWAY) {
306      isOverridden = true
307      toast($, `override set, this turn runs past the ${over.limitText} limit`)
308      return next(e)
309    }
310
311    // a dismissed dialog, or a host with no one to ask, comes back as null
312    return { drop: dropReasonOf(over) }
313  })
314
315  on('command.run', { command: COMMAND }, async ($, e, next) => {
316    await sample($)
317    const action = actionOf(e.args)
318
319    if (action.kind === 'error') return { text: action.text }
320
321    if (action.kind === 'show') {
322      paint($, figures())
323      const where = isStoreUsed
324        ? '\nSettings are kept in the plugin store.'
325        : '\nSettings are kept in the plugin settings (/config shows them).'
326      return { text: `${stateTextOf(settings, costUsd, limits, isOverridden)}${where}` }
327    }
328
329    if (action.kind === 'override') {
330      isOverridden = true
331      return {
332        text: 'Override set. The next turn runs even if a figure is past its limit. It lasts one turn.',
333      }
334    }
335
336    // every remaining action changes one field: apply it, keep it, repaint
337    const field: Field = action.kind === 'limit' ? action.field : action.kind
338    const value: string | number | boolean =
339      action.kind === 'limit' ? action.value : action.kind === 'mode' ? action.mode : action.enabled
340    settings = { ...settings, [field]: value }
341    const saved = await save($, field, value)
342    announced = {}
343    await refresh($, false)
344
345    if (action.kind === 'mode') {
346      const what =
347        action.mode === 'block'
348          ? 'Tool calls are refused once a figure passes its limit.'
349          : 'Nothing is refused; the guard only says so.'
350      return { text: `${action.mode} mode. ${what}${savedNote(saved)}` }
351    }
352
353    if (action.kind === 'enabled') {
354      return {
355        text: action.enabled
356          ? `on, in ${settings.mode} mode.${savedNote(saved)}`
357          : `off. Nothing is watched until /guard on.${savedNote(saved)}`,
358      }
359    }
360
361    const label = action.key === 'cost' ? 'session cost' : WINDOWS[action.key].label
362    if (action.value === 0) return { text: `The ${label} guard is off.${savedNote(saved)}` }
363
364    const reading = figures().find(figure => figure.key === action.key)
365    const now =
366      reading === undefined
367        ? ''
368        : ` It reads ${reading.valueText} now (${reading.percentOfLimit}% of the limit).`
369
370    return {
371      text: `${sentenceCase(label)} limit is ${action.key === 'cost' ? usd(action.value) : `${action.value}%`}.${now}${savedNote(saved)}`,
372    }
373  })
374}
375
hooks/guard.ts 331 lines
1import type { SessionRateLimit } from 'claude-code'
2
3// The rules behind the guard: what the settings are, what level each guarded
4// figure sits at, and the text every surface shows. Nothing here touches `$`,
5// so a failure in the engine can never reach it.
6
7/** How close a figure is to its limit. */
8export type Level = 'ok' | 'warn' | 'over'
9
10/** What the guard does once a figure is over: refuse work, or only say so. */
11export type Mode = 'block' | 'warn'
12
13/** The three guarded figures. */
14export type FigureKey = 'cost' | '5h' | '7d'
15
16/** The fields of `userConfig`, the same names the settings are stored under. */
17export type Field = keyof Settings
18
19export type Settings = {
20  costLimitUsd: number
21  fiveHourLimitPercent: number
22  sevenDayLimitPercent: number
23  warnAtPercent: number
24  mode: Mode
25  enabled: boolean
26}
27
28/** The same defaults `userConfig` declares, so the module works without it. */
29export const DEFAULTS: Settings = {
30  costLimitUsd: 0,
31  fiveHourLimitPercent: 90,
32  sevenDayLimitPercent: 95,
33  warnAtPercent: 80,
34  mode: 'block',
35  enabled: true,
36}
37
38/** Lays `raw` over `base`, keeping `base` wherever a value does not fit. */
39export function settingsOf(base: Settings, raw: unknown): Settings {
40  if (typeof raw !== 'object' || raw === null) return base
41  const held = raw as Record<string, unknown>
42
43  // a settings file edited by hand may hold "5" or "false"; they mean 5 and false
44  const num = (field: Field, was: number): number => {
45    const raw = held[field]
46    const value = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : raw
47    return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : was
48  }
49
50  const mode = held['mode']
51  const rawEnabled = held['enabled']
52  const enabled =
53    rawEnabled === 'true' ? true : rawEnabled === 'false' ? false : rawEnabled
54
55  return {
56    costLimitUsd: num('costLimitUsd', base.costLimitUsd),
57    fiveHourLimitPercent: num('fiveHourLimitPercent', base.fiveHourLimitPercent),
58    sevenDayLimitPercent: num('sevenDayLimitPercent', base.sevenDayLimitPercent),
59    warnAtPercent: Math.min(100, Math.max(1, num('warnAtPercent', base.warnAtPercent))),
60    mode: mode === 'block' || mode === 'warn' ? mode : base.mode,
61    enabled: typeof enabled === 'boolean' ? enabled : base.enabled,
62  }
63}
64
65/** Dollars as the guard prints them, always two decimals. */
66export const usd = (value: number): string => `$${value.toFixed(2)}`
67
68/** One guarded figure with a limit and a reading, ready to show. */
69export type Figure = {
70  key: FigureKey
71  /** `session cost`, `5-hour window`. */
72  label: string
73  value: number
74  limit: number
75  /** `$4.10` or `76%`. */
76  valueText: string
77  /** `$5.00` or `90%`. */
78  limitText: string
79  /** How far along the limit the reading is, as a whole percent. */
80  percentOfLimit: number
81  level: Level
82}
83
84export function levelOf(value: number, limit: number, warnAtPercent: number): Level {
85  if (limit <= 0) return 'ok'
86  if (value >= limit) return 'over'
87  return (value / limit) * 100 >= warnAtPercent ? 'warn' : 'ok'
88}
89
90/** The two plan windows: what the API calls them, and what guards them here. */
91export const WINDOWS = {
92  '5h': { kind: 'five_hour', label: '5-hour window', field: 'fiveHourLimitPercent' },
93  '7d': { kind: 'seven_day', label: '7-day window', field: 'sevenDayLimitPercent' },
94} as const satisfies Record<'5h' | '7d', { kind: string; label: string; field: Field }>
95
96/** The limit set for one window, in percent; 0 means the guard is off. */
97export const windowLimitOf = (settings: Settings, key: '5h' | '7d'): number =>
98  settings[WINDOWS[key].field]
99
100/** The percent a window reads now, or null when the plan reports none. */
101export function windowPercentOf(
102  limits: readonly SessionRateLimit[],
103  key: '5h' | '7d',
104): number | null {
105  const found = limits.find(limit => limit.kind === WINDOWS[key].kind)
106  if (found === undefined || !Number.isFinite(found.percentUsed)) return null
107  // the API means whole percent, but does not always send it that way
108  return Math.round(found.percentUsed)
109}
110
111/**
112 * The figures that are guarded right now: a limit of 0 turns one off, and a
113 * reading the session does not have (no cost ledger, no plan windows) leaves
114 * its figure out rather than guessing a zero.
115 */
116export function figuresOf(
117  costUsd: number | null,
118  limits: readonly SessionRateLimit[],
119  settings: Settings,
120): Figure[] {
121  const out: Figure[] = []
122
123  if (costUsd !== null && settings.costLimitUsd > 0) {
124    out.push({
125      key: 'cost',
126      label: 'session cost',
127      value: costUsd,
128      limit: settings.costLimitUsd,
129      valueText: usd(costUsd),
130      limitText: usd(settings.costLimitUsd),
131      percentOfLimit: Math.round((costUsd / settings.costLimitUsd) * 100),
132      level: levelOf(costUsd, settings.costLimitUsd, settings.warnAtPercent),
133    })
134  }
135
136  for (const key of ['5h', '7d'] as const) {
137    const limit = windowLimitOf(settings, key)
138    const value = windowPercentOf(limits, key)
139    if (value === null || limit <= 0) continue
140    out.push({
141      key,
142      label: WINDOWS[key].label,
143      value,
144      limit,
145      valueText: `${value}%`,
146      limitText: `${limit}%`,
147      percentOfLimit: Math.round((value / limit) * 100),
148      level: levelOf(value, limit, settings.warnAtPercent),
149    })
150  }
151
152  return out
153}
154
155/** The figure a deny or a drop speaks for: cost first, then 5h, then 7d. */
156export const firstOver = (figures: readonly Figure[]): Figure | undefined =>
157  figures.find(figure => figure.level === 'over')
158
159/**
160 * The one line pinned under the prompt while a figure is at warn or over, and
161 * `undefined` while everything is fine, which clears the line.
162 */
163export function statusOf(figures: readonly Figure[]): string | undefined {
164  if (!figures.some(figure => figure.level !== 'ok')) return undefined
165  const parts = figures.map(figure =>
166    figure.key === 'cost'
167      ? `cost ${figure.valueText}/${figure.limitText} ${figure.percentOfLimit}%`
168      : `${figure.key} ${figure.value}/${figure.limit}%`,
169  )
170  const tail = figures.some(figure => figure.level === 'over') ? ' · over limit' : ''
171  return `${parts.join(' · ')}${tail}`
172}
173
174/**
175 * `session cost $4.10 of $5.00 (82%)`.
176 *
177 * The engine puts the plugin's name in front of every toast, status line and
178 * command output it draws, so none of these texts carries one of its own.
179 */
180export const warnTextOf = (figure: Figure): string =>
181  figure.key === 'cost'
182    ? `session cost ${figure.valueText} of ${figure.limitText} (${figure.percentOfLimit}%)`
183    : `${figure.label} ${figure.valueText} of the ${figure.limitText} limit (${figure.percentOfLimit}%)`
184
185/** A limit worth raising to, given how far past the reading already is. */
186export function raiseHintOf(figure: Figure): string {
187  if (figure.key === 'cost') {
188    return `/guard cost ${Math.max(1, Math.ceil(figure.value * 1.5))}`
189  }
190  const next = Math.min(100, Math.max(figure.limit + 5, Math.ceil(figure.value / 5) * 5))
191  return `/guard ${figure.key} ${next}`
192}
193
194/** What a figure being over says, without a plugin name in front. */
195export const overTextOf = (figure: Figure, what: string): string =>
196  `${figure.label} ${figure.valueText} passed the ${figure.limitText} limit. ` +
197  `${what} ${raiseHintOf(figure)} raises the limit.`
198
199/**
200 * A deny and a drop reach the model and the transcript unnamed, so these two
201 * carry the plugin's name themselves.
202 */
203export const denyReasonOf = (figure: Figure): string =>
204  `budget-guard: ${figure.label} ${figure.valueText} passed the ${figure.limitText} limit, so the turn was stopped. ` +
205  `/guard override allows the next turn; ${raiseHintOf(figure)} raises the limit.`
206
207export const dropReasonOf = (figure: Figure): string =>
208  `budget-guard: ${overTextOf(figure, 'The prompt was not sent. /guard override sends the next one;')}`
209
210/** The toast shown once a turn in warn mode, where nothing is ever refused. */
211export const warnModeTextOf = (figure: Figure): string =>
212  `${figure.label} ${figure.valueText} is past the ${figure.limitText} limit (warn mode, nothing is blocked)`
213
214/** What `/guard <args>` asked for. */
215export type Action =
216  | { kind: 'show' }
217  | { kind: 'override' }
218  | { kind: 'mode'; mode: Mode }
219  | { kind: 'enabled'; enabled: boolean }
220  | { kind: 'limit'; key: FigureKey; field: Field; value: number }
221  | { kind: 'error'; text: string }
222
223const LIMIT_FIELD: Record<FigureKey, Field> = {
224  cost: 'costLimitUsd',
225  '5h': WINDOWS['5h'].field,
226  '7d': WINDOWS['7d'].field,
227}
228
229export function actionOf(args: string): Action {
230  const words = args.trim().toLowerCase().split(/\s+/).filter(word => word.length > 0)
231  const head = words[0]
232
233  if (head === undefined) return { kind: 'show' }
234  if (head === 'override') return { kind: 'override' }
235  if (head === 'warn' || head === 'block') return { kind: 'mode', mode: head }
236  if (head === 'off') return { kind: 'enabled', enabled: false }
237  if (head === 'on') return { kind: 'enabled', enabled: true }
238
239  if (head === 'cost' || head === '5h' || head === '7d') {
240    const raw = words[1]
241    if (raw === undefined) return { kind: 'error', text: `/guard ${head} needs a number, for example /guard ${head} ${head === 'cost' ? '5' : '90'}` }
242    const value = Number(raw)
243    if (!Number.isFinite(value) || value < 0) {
244      return { kind: 'error', text: `"${raw}" is not a number the guard can use. Give it 0 or more.` }
245    }
246    if (head !== 'cost' && value > 100) {
247      return { kind: 'error', text: `A window limit is a percent, so it cannot be over 100.` }
248    }
249    return { kind: 'limit', key: head, field: LIMIT_FIELD[head], value }
250  }
251
252  return { kind: 'error', text: `/guard does not know "${head}". Run /guard on its own to see what it takes.` }
253}
254
255/** One row of the `/guard` state print, padded so the columns line up. */
256type Row = { name: string; reading: string; level: string }
257
258const padded = (rows: readonly Row[]): string[] => {
259  const nameWidth = Math.max(...rows.map(row => row.name.length))
260  const readingWidth = Math.max(...rows.map(row => row.reading.length))
261  return rows.map(
262    row => `  ${row.name.padEnd(nameWidth)}  ${row.reading.padEnd(readingWidth)}  ${row.level}`,
263  )
264}
265
266const HELP = [
267  'Change it:',
268  '  /guard cost 5     dollar limit for this session (0 turns it off)',
269  '  /guard 5h 90      percent of the 5-hour plan window',
270  '  /guard 7d 95      percent of the 7-day plan window',
271  '  /guard warn       only say so, never refuse',
272  '  /guard block      refuse tool calls once a limit is passed',
273  '  /guard override   allow the next turn even if a figure is over',
274  '  /guard off        turn the guard off    (/guard on turns it back)',
275]
276
277/** Everything `/guard` on its own prints. */
278export function stateTextOf(
279  settings: Settings,
280  costUsd: number | null,
281  limits: readonly SessionRateLimit[],
282  isOverridden: boolean,
283): string {
284  const figures = figuresOf(costUsd, limits, settings)
285  const byKey = new Map(figures.map(figure => [figure.key, figure]))
286
287  const rows: Row[] = [
288    {
289      name: 'session cost',
290      reading:
291        costUsd === null
292          ? 'no cost reported'
293          : settings.costLimitUsd <= 0
294            ? `${usd(costUsd)}, no limit`
295            : `${usd(costUsd)} of ${usd(settings.costLimitUsd)}`,
296      level: byKey.get('cost')?.level ?? 'not guarded',
297    },
298  ]
299
300  for (const key of ['5h', '7d'] as const) {
301    const limit = windowLimitOf(settings, key)
302    const value = windowPercentOf(limits, key)
303    rows.push({
304      name: WINDOWS[key].label,
305      reading:
306        value === null
307          ? 'not reported by the plan'
308          : limit <= 0
309            ? `${value}%, no limit`
310            : `${value}% of ${limit}%`,
311      level: byKey.get(key)?.level ?? 'not guarded',
312    })
313  }
314
315  const head = settings.enabled
316    ? `on, in ${settings.mode} mode. It warns at ${settings.warnAtPercent}% of a limit.`
317    : 'off. Nothing is watched until /guard on.'
318
319  const notes: string[] = []
320  if (isOverridden) notes.push('An override is set: the next turn runs even if a figure is over.')
321  if (limits.length === 0) {
322    notes.push(
323      'No plan windows reported. API-key sessions never report them, so the 5h and 7d guards stay inactive.',
324    )
325  }
326
327  return [head, '', ...padded(rows), '', ...notes, ...(notes.length > 0 ? [''] : []), ...HELP].join(
328    '\n',
329  )
330}
331