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…

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:
It stays invisible while everything is fine. No status line, no toasts, nothing.
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.
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.

/guardRun /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.
The six settings are the plugin's own userConfig fields, so /config shows them too.
| Field | Default | What it is |
|---|---|---|
costLimitUsd | 0 | Dollars this session may spend. 0 turns the cost guard off. |
fiveHourLimitPercent | 90 | Percent of the 5-hour plan window allowed. |
sevenDayLimitPercent | 95 | Percent of the 7-day plan window allowed. |
warnAtPercent | 80 | How far along a limit counts as a warning. |
mode | block | block refuses work past a limit, warn only says so. |
enabled | true | Turn 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.
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.
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.
hooks/register.tsx 375 lines1/* @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}
375hooks/guard.ts 331 lines1import 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