A quiet usage line above the prompt that appears only when context, a plan window or a dollar limit needs a look, and an optional limit (plan-window…

A quiet usage line and an optional limit that asks first: on your plan's usage windows, or in dollars for API-key use. Everything runs locally: no model call, no tokens, no network.
contextWarnPct, a plan window (5h / 7d) reaches 80% (or 90% of your limit), or cost reaches 90% of a dollar limit: Session ~$1.20 · context 78% ! · 5h window 82/100 · limit 80 !! (! = high, !! = very high; also coloured). It steps aside for surveys. With a dollar limit the cost reads Session ~$9.50 · limit $10 !.limitPct (default 0 = none) is in plan points out of 100: when ANY plan window is at or above it, the next prompt you type opens one dialog. limitUsd (default 0 = none) is in dollars, for API-key use where no plan window is reported: the dialog opens when the estimated session cost reaches it. Either (or both at once, in one dialog) triggers it. Choices: Continue (+10 points, max 100 / +50% of the dollar limit), Continue without limit, or Hold it. Esc holds: "usage-meter blocked your prompt · nothing was sent" and your text is echoed back. It never stops tool calls inside a running turn and never touches scheduled, queued or plugin prompts.showPace). For the 5h and 7d windows the mod projects the use at the reset from the burn rate so far (percent used / fraction of the window elapsed). 100% or more projected is red, 90% or more amber, otherwise nothing. A window on pace is marked ▲ in the line (and shown even below 80%): 5h window 62/100 ▲ !!; the report says ▲ on pace to run out in ~1h 32m. A window under 10% used, or under 2% of the way in, is never flagged. It is a hint only: nothing is ever blocked or asked.Today $x, and Week $y (since the weekly 7d window opened, by whole days) when the account reports a 7d window (showDailyCost, showWeeklyCost). In the line they appear only once it is showing and above $0. Only the engine's own cost counts, never the token estimate. Other mods can read it as the atom { plugin: 'usage-meter', key: 'ledger' } = { today: number, week: number | null }./usage-meter prints a short report: cost, tokens in/out, cache hit ratio, context fill, last and longest turn, and each plan window as 5h window 62/100 · limit 80 · resets in 1h 12m./usage-meter 80 sets the plan limit to 80 points, /usage-meter $10 the dollar limit to $10, for this session only (/usage-meter 0 or /usage-meter off clears both). Set limitPct / limitUsd in /config so every session has one. Typed words like usage are ordinary prompts; only the slash command counts./usage-meter ledger prints the last 7 days; /usage-meter ledger clear forgets all daily totals (no confirmation: it only forgets)./plugin disable usage-meter, or set enabled to false.
enabled, limitPct (0-100, plan points), limitUsd (USD per session), contextWarnPct (default 75), showLine (default true), showPace (default true), showDailyCost (default true), showWeeklyCost (default true).
/clear. The /usage-meter N override is not saved.hooks/register.tsx 361 lines1// usage-meter: a quiet usage line and a spending limit that asks instead of refusing.
2//
3// - The band above the prompt is HIDDEN until something matters (a full context window, a plan window
4// near its cap or your limit). Then it shows "Session ~$1.20 · context 78% ! · 5h window 82/100 · limit 80 !".
5// - The limit is in plan points (percent of a plan window used, out of 100). With a limit set, the NEXT
6// prompt the person types once ANY plan window is at or above it opens one dialog: continue (+10 points),
7// continue without a limit, or hold. It never touches tool calls in a running turn and never stops a
8// scheduled, queued or plugin prompt. With no plan window reported (API-key use), a dollar limit
9// (`limitUsd`, `/usage-meter $10`) on the session cost estimate does the same job, with continue (+50%).
10// - `/usage-meter` prints a short report; `/usage-meter 80` sets the limit for this session (`0` or
11// `off` removes it). All of it is local: no model call, no network, zero tokens.
12//
13// FAIL OPEN everywhere: if reading the usage, the state or the dialog throws, the prompt goes through
14// exactly as typed. The only deliberate drop is a prompt the person chose to hold (Esc or "Hold it").
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import { EMPTY_SNAP, EMPTY_TALLY, addTurn, addUsage, bandParts, buildReport, costOf, fmtLimit, ledgerReport, fmtReset, fmtUsd, fmtUsdLimit, fmtUsed, overLimit, parseArgs, raisedLimit, raisedUsd, usdReached, windowLabel } from './lib'
19import type { Level, TokenCounts, Word } from './lib'
20import { addSpend, atomOf, cleanLedger, deltaOf } from './ledger'
21import type { Ledger, LedgerAtom, LedgerLast } from './ledger'
22import { dropWith } from './reply'
23import type { BudgetSnap } from '../types'
24
25type Dollar = EngineInterface
26type Cfg = { isEnabled: boolean; limitPct: number; limitUsd: number; contextWarnPct: number; showLine: boolean; showPace: boolean; showDailyCost: boolean; showWeeklyCost: boolean }
27
28const tally = atom({ plugin: 'usage-meter', key: 'tally' } as const, EMPTY_TALLY)
29const turns = atom({ plugin: 'usage-meter', key: 'turns' } as const, [])
30const turnStart = atom({ plugin: 'usage-meter', key: 'turnStart' } as const, null)
31const limit = atom({ plugin: 'usage-meter', key: 'limit' } as const, null)
32const limitUsdAtom = atom({ plugin: 'usage-meter', key: 'limitUsd' } as const, null)
33const snapshot = atom({ plugin: 'usage-meter', key: 'snap' } as const, EMPTY_SNAP)
34/** The ledger mirror the moddeck side bar reads: today's spend, and the week since the weekly window opened (null without one). */
35const ledgerAtom = atom({ plugin: 'usage-meter', key: 'ledger' } as const, { today: 0, week: null } as LedgerAtom)
36/** The last cost reading of this session, so only growth is added to the ledger. */
37const lastCost = atom({ plugin: 'usage-meter', key: 'lastCost' } as const, null)
38
39const LEDGER_KEY = 'ledger'
40
41const COLOUR: Record<Level, 'warning' | 'error' | undefined> = { ok: undefined, warn: 'warning', error: 'error' }
42
43const MORE_POINTS = 'Continue (+10 points)'
44const MORE_USD = 'Continue (+50%)'
45const MORE_BOTH = 'Continue (+10 points / +50%)'
46const CONTINUE_FREE = 'Continue without limit'
47const HOLD = 'Hold it'
48
49const numberOf = (v: unknown, fallback: number, lo: number, hi: number): number =>
50 typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback
51
52export function readConfig(o: Readonly<Record<string, unknown>>): Cfg {
53 return {
54 isEnabled: o.enabled !== false,
55 limitPct: numberOf(o.limitPct, 0, 0, 100),
56 limitUsd: numberOf(o.limitUsd, 0, 0, 1_000_000),
57 contextWarnPct: numberOf(o.contextWarnPct, 75, 10, 99),
58 showLine: o.showLine !== false,
59 showPace: o.showPace !== false,
60 showDailyCost: o.showDailyCost !== false,
61 showWeeklyCost: o.showWeeklyCost !== false,
62 }
63}
64
65async function quietly<T>(f: () => T | Promise<T>): Promise<T | undefined> {
66 try {
67 return await f()
68 } catch {
69 return undefined
70 }
71}
72
73/** What the engine reports as the usage right now, in the shape the band and the report draw from. */
74type Reading = {
75 context: { percent?: number; tokens?: number; window: number }
76 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
77 cost?: { usd: number }
78}
79
80function toSnap(r: Reading): BudgetSnap {
81 const { window, tokens } = r.context
82 const pct = r.context.percent ?? (tokens !== undefined && window > 0 ? (tokens / window) * 100 : undefined)
83 return {
84 usd: r.cost?.usd ?? null,
85 ctxPct: pct ?? null,
86 ctxTokens: tokens ?? null,
87 ctxWindow: window > 0 ? window : null,
88 windows: r.rateLimits.map(w => ({ kind: w.kind, pct: w.percentUsed, ...(w.resetsAt === undefined ? {} : { resetsAt: w.resetsAt }) })),
89 }
90}
91
92const effectiveLimit = async ($: Dollar, cfg: Cfg): Promise<number> => (await read($, limit)) ?? cfg.limitPct
93const effectiveUsd = async ($: Dollar, cfg: Cfg): Promise<number> => (await read($, limitUsdAtom)) ?? cfg.limitUsd
94
95/** A fresh reading, also stored so the band and the report see it. Undefined when the engine has none. */
96async function refresh($: Dollar): Promise<BudgetSnap | undefined> {
97 const usage = await quietly(() => $.session.usage())
98 if (usage === undefined) return undefined
99 const snap = toSnap(usage)
100 await quietly(() => update($, snapshot, () => snap))
101 return snap
102}
103
104// ------------------------------------------------------------------ the cost ledger
105
106const readLedger = async ($: Dollar): Promise<Ledger> => cleanLedger(await quietly(() => $.store.get(LEDGER_KEY)))
107
108async function mirror($: Dollar, l: Ledger, snap: BudgetSnap): Promise<LedgerAtom> {
109 const a = atomOf(l, await $.clock.now(), snap.windows)
110 await quietly(() => update($, ledgerAtom, () => a))
111 return a
112}
113
114/** Adds the growth of the session cost since the last reading to today's total, and mirrors the atom. Observe only: never throws. */
115async function trackCost($: Dollar, usd: number | undefined, snap: BudgetSnap): Promise<void> {
116 await quietly(async () => {
117 let l = await readLedger($)
118 if (usd !== undefined && Number.isFinite(usd)) {
119 const prev = ((await read($, lastCost)) as LedgerLast | null) ?? null
120 const startedAt = prev !== null ? (await quietly(() => $.session.usage()))?.startedAt ?? prev.startedAt : (await quietly(() => $.session.usage()))?.startedAt
121 if (startedAt !== undefined) {
122 const now = await $.clock.now()
123 const { delta, last } = deltaOf(prev, startedAt, usd, now)
124 await update($, lastCost, () => last)
125 if (delta > 0) {
126 l = addSpend(l, now, delta)
127 await $.store.set(LEDGER_KEY, l)
128 }
129 }
130 }
131 await mirror($, l, snap)
132 })
133}
134
135async function ledgerNow($: Dollar, snap: BudgetSnap): Promise<LedgerAtom> {
136 return (await quietly(async () => mirror($, await readLedger($), snap))) ?? (await read($, ledgerAtom))
137}
138
139async function ledgerCmd($: Dollar, clear: boolean): Promise<string> {
140 const now = await $.clock.now()
141 if (clear) {
142 await $.store.set(LEDGER_KEY, {})
143 await quietly(() => update($, ledgerAtom, () => ({ today: 0, week: null })))
144 await ledgerNow($, await read($, snapshot))
145 return 'usage-meter · cost ledger cleared (it only forgets the daily totals)'
146 }
147 return ledgerReport(await readLedger($), now)
148}
149
150// ------------------------------------------------------------------ the report and the limit
151
152async function report($: Dollar, cfg: Cfg): Promise<string> {
153 const snap = (await refresh($)) ?? (await read($, snapshot))
154 if (snap.usd !== null) await trackCost($, snap.usd, snap)
155 const ledger = await ledgerNow($, snap)
156 return buildReport({
157 snap,
158 pace: cfg.showPace,
159 ledger,
160 showDaily: cfg.showDailyCost,
161 showWeekly: cfg.showWeeklyCost,
162 tally: await read($, tally),
163 turns: await read($, turns),
164 limitPct: await effectiveLimit($, cfg),
165 limitUsd: await effectiveUsd($, cfg),
166 now: await $.clock.now(),
167 })
168}
169
170async function setLimit($: Dollar, pct: number): Promise<string> {
171 await update($, limit, () => pct)
172 if (pct === 0) {
173 await update($, limitUsdAtom, () => 0) // 0 or off clears both limits
174 return 'usage-meter · no limit for this session'
175 }
176 const snap = (await refresh($)) ?? (await read($, snapshot))
177 const idle = snap.windows.length === 0 ? '\nNo plan window is reported (API-key use), so this limit cannot be enforced; use a dollar limit like /usage-meter $10.' : ''
178 return `usage-meter · limit ${fmtLimit(pct)}/100 for this session${idle}`
179}
180
181async function setUsd($: Dollar, usd: number): Promise<string> {
182 await update($, limitUsdAtom, () => usd)
183 return usd === 0 ? 'usage-meter · no dollar limit for this session' : `usage-meter · limit ${fmtUsdLimit(usd)} for this session`
184}
185
186async function run($: Dollar, cfg: Cfg, word: Word): Promise<string> {
187 if (word.kind === 'ledger' || word.kind === 'ledgerClear') return ledgerCmd($, word.kind === 'ledgerClear')
188 return word.kind === 'set' ? setLimit($, word.pct) : word.kind === 'setUsd' ? setUsd($, word.usd) : report($, cfg)
189}
190
191// -------------------------------------------------------------------------------- the guard
192
193/** A prompt the person typed at an idle session: the only kind that is ever asked about. */
194const isGuarded = (e: { text: string; origin: { kind: string }; turnId?: string }): boolean =>
195 (e.origin.kind === 'composer' || e.origin.kind === 'bridge') && e.turnId === undefined && e.text.trim() !== '' && !e.text.trimStart().startsWith('/')
196
197/** Asks once if a plan window is at or above the percent limit OR the cost is at or above the dollar limit; returns a drop when the person holds the prompt, else undefined. */
198async function guard($: Dollar, cfg: Cfg, text: string): Promise<{ drop: string } | undefined> {
199 const maxPct = await effectiveLimit($, cfg)
200 const maxUsd = await effectiveUsd($, cfg)
201 if (maxPct <= 0 && maxUsd <= 0) return undefined
202 const snap = (await refresh($)) ?? (await read($, snapshot))
203 const hit = overLimit(snap.windows, maxPct)
204 const spent = costOf(snap, await read($, tally)).usd
205 const usdHit = usdReached(spent, maxUsd)
206 if (hit === undefined && !usdHit) return undefined
207
208 const reset = hit === undefined ? undefined : fmtReset(hit.resetsAt, await $.clock.now())
209 const win = hit === undefined ? '' : `${windowLabel(hit.kind)} is at ${fmtUsed(hit.pct)}${reset === undefined ? '' : ` (resets in ${reset})`}, at or above your limit of ${fmtLimit(maxPct)}`
210 const cost = `${hit === undefined ? 'Session cost' : 'session cost'} is ~${fmtUsd(spent)}, at or above your limit of ${fmtUsdLimit(maxUsd)}`
211 const question = `${win}${hit !== undefined && usdHit ? ', and ' : ''}${usdHit ? cost : ''}. Continue?`
212 const more = hit !== undefined && usdHit ? MORE_BOTH : usdHit ? MORE_USD : MORE_POINTS
213 let answer: string
214 try {
215 answer = await $.ui.ask(question, { options: [more, CONTINUE_FREE, HOLD], header: 'Budget' })
216 } catch {
217 answer = HOLD // Esc or an interrupt: nothing is sent
218 }
219 if (answer === more) {
220 if (hit !== undefined) await update($, limit, () => raisedLimit(maxPct))
221 if (usdHit) await update($, limitUsdAtom, () => raisedUsd(maxUsd))
222 return undefined
223 }
224 if (answer === CONTINUE_FREE) {
225 await update($, limit, () => 0)
226 await update($, limitUsdAtom, () => 0)
227 return undefined
228 }
229 const shown = text.length > 2000 ? `${text.slice(0, 2000)}...` : text
230 return dropWith(note => $.ui.log(note), `usage-meter blocked your prompt · nothing was sent\n${shown}`)
231}
232
233// ------------------------------------------------------------------------------ the register
234
235/** What the hooks below this one drew in the band. Only one tree can fill the band, so a mod that draws returns its row with this under it (never instead of it), and the other mods' rows stay visible. */
236async function drawnBelow<E>(next: (e: E) => unknown, e: E): Promise<object | undefined> {
237 try {
238 const tree = await next(e)
239 return tree !== null && typeof tree === 'object' ? tree : undefined
240 } catch {
241 return undefined
242 }
243}
244
245export const register: Register = (on, options) => {
246 const cfg = readConfig(options)
247
248 on('session.start', async ($, e, next) => {
249 if (cfg.isEnabled) {
250 const snap = await refresh($)
251 if (snap !== undefined) await trackCost($, snap.usd ?? undefined, snap)
252 await quietly(() =>
253 $.command.register({ name: 'usage-meter', description: 'Usage report, or a limit for this session: 0-100 plan points, or $10 dollars; 0 or off clears both', argumentHint: '[0-100|$usd|off|ledger|ledger clear]' }),
254 )
255 }
256 return next(e)
257 }).catch(($, e, next) => next(e))
258
259 // /clear starts the engine's own ledger over; so do the totals kept here.
260 on('session.end', async ($, e, next) => {
261 if (cfg.isEnabled && e.reason === 'clear') {
262 await quietly(async () => {
263 await update($, tally, () => EMPTY_TALLY)
264 await update($, turns, () => [])
265 await update($, limit, () => null)
266 await update($, limitUsdAtom, () => null)
267 await update($, snapshot, () => EMPTY_SNAP)
268 await update($, lastCost, () => null)
269 })
270 }
271 return next(e)
272 }).catch(($, e, next) => next(e))
273
274 // The engine pushes a reading after each turn and when a plan window moves a whole point.
275 on('session.measure', async ($, e, next) => {
276 if (cfg.isEnabled) {
277 const snap = toSnap(e)
278 await quietly(() => update($, snapshot, () => snap))
279 await trackCost($, e.cost?.usd, snap)
280 }
281 return next(e)
282 }).catch(($, e, next) => next(e))
283
284 on('turn.start', async ($, e, next) => {
285 if (cfg.isEnabled) {
286 await quietly(async () => {
287 const now = await $.clock.now()
288 await update($, turnStart, () => now)
289 })
290 }
291 return next(e)
292 }).catch(($, e, next) => next(e))
293
294 on('turn.complete', async ($, e, next) => {
295 if (cfg.isEnabled) await quietly(() => record($, e))
296 return next(e)
297 }).catch(($, e, next) => next(e))
298
299 on('command.run', { command: 'usage-meter' }, async ($, e) => {
300 if (!cfg.isEnabled) return { text: 'usage-meter · disabled\nThe enabled setting is false. Change it in /config.' }
301 return { text: await run($, cfg, parseArgs(e.args)) }
302 }).catch(() => ({ text: 'usage-meter · that did not work, nothing changed' }))
303
304 // The band: its own small tree like every other band here, hidden until something matters, and it
305 // steps aside for a survey. Parts carry colour AND a trailing "!" so the level reads without colour.
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 if (!cfg.isEnabled || !cfg.showLine || e.props.hasSurvey) return next(e)
308 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
309 const parts = bandParts(
310 await read($, snapshot),
311 await read($, tally),
312 { limitPct: await effectiveLimit($, cfg), limitUsd: await effectiveUsd($, cfg), contextWarnPct: cfg.contextWarnPct },
313 { pace: cfg.showPace, now: await $.clock.now(), ledger: await read($, ledgerAtom), showDaily: cfg.showDailyCost, showWeekly: cfg.showWeeklyCost },
314 )
315 if (parts === undefined) return next(e)
316 const { Box, Button, Text } = $.ui.resolve(e)
317 // A plan window opens Claude Code's own /usage. A mod cannot run a built-in command, so the click puts it in the
318 // prompt box: one Enter runs it.
319 const openUsage = () => void $.prompt.fill({ text: '/usage' }).then(() => $.ui.toast('Press Enter to run /usage'), () => undefined)
320 const row = (
321 <Box key="usage-meter-row">
322 {parts.flatMap((p, i) => [
323 ...(i > 0 ? [<Text key={`sep${i}`} dimColor>{' · '}</Text>] : []),
324 p.isWindow ? (
325 <Button key={`part${i}`} plain onPress={openUsage}>
326 <Text color={COLOUR[p.level]} bold={p.level === 'error'}>{p.text}</Text>
327 </Button>
328 ) : (
329 <Text key={`part${i}`} color={COLOUR[p.level]} bold={p.level === 'error'}>{p.text}</Text>
330 ),
331 ])}
332 </Box>
333 )
334 const below = await drawnBelow(next, e)
335 return below === undefined ? row : <Box key="usage-meter-band" flexDirection="column">{row}{below as never}</Box>
336 }).catch(($, e, next) => next(e))
337
338 // Exactly one `next` per path. Only a held prompt is dropped; typed words like `usage` are ordinary prompts.
339 on('prompt.submit', async ($, e, next) => {
340 if (!cfg.isEnabled) return next(e)
341 if (isGuarded(e)) {
342 const stop = await guard($, cfg, e.text).catch(() => undefined) // fail open
343 if (stop !== undefined) return stop
344 }
345 return next(e)
346 }).catch(($, e, next) => next(e)) // fail open; if `next` already ran this replays its result
347}
348
349/** One finished turn: its tokens go into the totals, a main-loop turn also into the timing list. */
350async function record($: Dollar, e: { agentId?: string; durationMs: number; usage?: TokenCounts & { model: string } }): Promise<void> {
351 if (e.usage !== undefined) await update($, tally, t => addUsage(t, e.usage!.model, e.usage!))
352 if (e.agentId !== undefined) return // a subagent: its cost counts, its timing is not a turn of the session
353 const started = await read($, turnStart)
354 const ms = Number.isFinite(e.durationMs) && e.durationMs >= 0 ? e.durationMs : started === null ? 0 : (await $.clock.now()) - started
355 const u = e.usage
356 const tokens = u === undefined ? 0 : u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
357 await update($, turns, list => addTurn(list, { ms, tokens }))
358 await update($, tally, t => ({ ...t, turns: t.turns + 1, longestMs: Math.max(t.longestMs, ms) }))
359 await update($, turnStart, () => null)
360}
361hooks/lib.ts 286 lines1// Pure helpers: rough prices, formatting, the band's parts and the report. No engine access.
2import type { BudgetSnap, BudgetTally, BudgetTurn, BudgetWindow } from '../types'
3import type { Ledger, LedgerAtom } from './ledger'
4import { lastDays } from './ledger'
5import { paceOf, runOutMs } from './pace'
6import type { Pace } from './pace'
7
8// ---- prices (an estimate only, used where the engine keeps no cost ledger) -----------------
9
10/** USD per million tokens. */
11type Rate = { in: number; out: number }
12
13// Rough list prices; they drift, so every figure built from them is labelled "est.".
14// First match wins; an unknown model is priced like Sonnet.
15const PRICES: ReadonlyArray<readonly [RegExp, Rate]> = [
16 [/opus-4(-1)?(-\d{8})?$/, { in: 15, out: 75 }],
17 [/opus/, { in: 5, out: 25 }],
18 [/haiku-3/, { in: 0.8, out: 4 }],
19 [/haiku/, { in: 1, out: 5 }],
20 [/sonnet/, { in: 3, out: 15 }],
21]
22const FALLBACK: Rate = { in: 3, out: 15 }
23
24/** A cache write is billed at 1.25x the input price (5-minute tier), a read at 0.1x. */
25const WRITE_RATIO = 1.25
26const READ_RATIO = 0.1
27
28export type TokenCounts = {
29 input_tokens: number
30 output_tokens: number
31 cache_read_input_tokens: number
32 cache_creation_input_tokens: number
33}
34
35export function estimateUsd(model: string, u: TokenCounts): number {
36 const id = model.toLowerCase()
37 const rate = PRICES.find(([re]) => re.test(id))?.[1] ?? FALLBACK
38 const inUsd = u.input_tokens + u.cache_creation_input_tokens * WRITE_RATIO + u.cache_read_input_tokens * READ_RATIO
39 return (inUsd * rate.in + u.output_tokens * rate.out) / 1e6
40}
41
42// ---- bookkeeping ---------------------------------------------------------------------------
43
44/** How many finished turns are kept in memory. */
45export const MAX_TURNS = 50
46
47export const EMPTY_TALLY: BudgetTally = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, estUsd: 0, turns: 0, longestMs: 0 }
48
49export const EMPTY_SNAP: BudgetSnap = { usd: null, ctxPct: null, ctxTokens: null, ctxWindow: null, windows: [] }
50
51/** Adds one turn's real token counts (and its estimated cost) to the totals. */
52export function addUsage(t: BudgetTally, model: string, u: TokenCounts): BudgetTally {
53 return {
54 ...t,
55 input: t.input + u.input_tokens,
56 output: t.output + u.output_tokens,
57 cacheRead: t.cacheRead + u.cache_read_input_tokens,
58 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
59 estUsd: t.estUsd + estimateUsd(model, u),
60 }
61}
62
63export const addTurn = (turns: BudgetTurn[], turn: BudgetTurn): BudgetTurn[] => [...turns, turn].slice(-MAX_TURNS)
64
65/** The session cost to show: the engine's ledger when it has one, else the token estimate. */
66export function costOf(snap: BudgetSnap, tally: BudgetTally): { usd: number; isEst: boolean } {
67 return snap.usd === null ? { usd: tally.estUsd, isEst: true } : { usd: snap.usd, isEst: false }
68}
69
70// ---- formatting ----------------------------------------------------------------------------
71
72export function fmtUsd(n: number): string {
73 if (n > 0 && n < 0.01) return '<$0.01'
74 return `$${n < 10 ? n.toFixed(2) : n < 100 ? n.toFixed(1) : n.toFixed(0)}`
75}
76
77/** A limit as the person typed it: 80, 82.5. */
78export const fmtLimit = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(1))
79
80/** A dollar limit as the person typed it: $10, $7.50. */
81export const fmtUsdLimit = (n: number): string => `$${Number.isInteger(n) ? n : n.toFixed(2)}`
82
83/** Used out of total, both in plan points: 82/100. */
84export const fmtUsed = (n: number): string => `${Math.round(n)}/100`
85
86export function fmtTokens(n: number): string {
87 if (n < 1000) return String(Math.round(n))
88 return n < 10_000 ? `${(n / 1000).toFixed(1)}k` : n < 1_000_000 ? `${Math.round(n / 1000)}k` : `${(n / 1e6).toFixed(1)}M`
89}
90
91export function fmtDuration(ms: number): string {
92 const s = Math.max(Math.round(ms / 1000), 0)
93 if (s < 60) return `${s}s`
94 const m = Math.floor(s / 60)
95 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
96 return `${Math.floor(m / 60)}h ${m % 60}m`
97}
98
99const MIN = 60_000
100
101/** Time until a window resets: "1h 12m", "35m", "soon"; undefined when the timestamp is unreadable. */
102export function fmtReset(resetsAt: string | undefined, now: number): string | undefined {
103 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
104 if (!Number.isFinite(at)) return undefined
105 const m = Math.round((at - now) / MIN)
106 if (m < 1) return 'soon'
107 return m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
108}
109
110export const fmtPct = (n: number): string => `${Math.round(n)}%`
111
112/** "5h window", "7d window"; any other kind is shown as the engine names it, spaced. */
113export function windowLabel(kind: string): string {
114 if (kind === 'five_hour') return '5h window'
115 if (kind === 'seven_day') return '7d window'
116 return kind.replace(/_/g, ' ')
117}
118
119// ---- the usage line ------------------------------------------------------------------------
120
121export type Level = 'ok' | 'warn' | 'error'
122/** `isWindow`: a plan window, which opens Claude Code's /usage when clicked. */
123export type Part = { text: string; level: Level; isWindow?: true }
124
125export type Thresholds = { limitPct: number; limitUsd: number; contextWarnPct: number }
126
127/** A dollar limit warns at 90% of it and is an error at 100%. */
128const USD_WARN_FRACTION = 0.9
129
130export const PLAN_WARN_PCT = 80
131const PLAN_ERROR_PCT = 95
132
133const levelAt = (v: number, warn: number, error: number): Level => (v >= error ? 'error' : v >= warn ? 'warn' : 'ok')
134
135/** The words that stand in for colour: a flagged part ends in "!" (warning) or "!!" (error). */
136export const MARK: Record<Level, string> = { ok: '', warn: ' !', error: ' !!' }
137
138/** A window text: "5h window 62/100", with " · limit 80" when a limit is set. */
139export const windowText = (w: BudgetWindow, limitPct: number): string =>
140 `${windowLabel(w.kind)} ${fmtUsed(w.pct)}${limitPct > 0 ? ` · limit ${fmtLimit(limitPct)}` : ''}`
141
142/** True when the session cost has reached a dollar limit (never with no limit). */
143export const usdReached = (usd: number, limitUsd: number): boolean => limitUsd > 0 && usd >= limitUsd
144
145/** The first plan window at or above the limit (none when there is no limit or no window). */
146export const overLimit = (windows: readonly BudgetWindow[], limitPct: number): BudgetWindow | undefined =>
147 limitPct > 0 ? windows.reduce<BudgetWindow | undefined>((hit, w) => (w.pct >= limitPct && (hit === undefined || w.pct > hit.pct) ? w : hit), undefined) : undefined
148
149/**
150 * The parts of the line, or undefined while nothing matters: context at the warning percentage, a plan
151 * window at 80% (or 90% of your limit), or session cost at 90% of a dollar limit. When one matters, cost
152 * and context are shown too, so the line reads as a whole; plan windows only when they themselves are high.
153 */
154export type BandExtras = {
155 /** Show the pace arrow (needs `now`). */
156 pace?: boolean
157 now?: number
158 /** The cost ledger mirror; Today / Week parts are added (when above zero) once the line is showing. */
159 ledger?: LedgerAtom
160 showDaily?: boolean
161 showWeekly?: boolean
162}
163
164const paceLevel = (p: Pace): Level => (p === 'red' ? 'error' : p === 'amber' ? 'warn' : 'ok')
165const worse = (a: Level, b: Level): Level => (a === 'error' || b === 'error' ? 'error' : a === 'warn' || b === 'warn' ? 'warn' : 'ok')
166
167export function bandParts(snap: BudgetSnap, tally: BudgetTally, t: Thresholds, x: BandExtras = {}): Part[] | undefined {
168 const { usd, isEst } = costOf(snap, tally)
169 const ctxLevel: Level = snap.ctxPct === null ? 'ok' : levelAt(snap.ctxPct, t.contextWarnPct, Math.max(90, t.contextWarnPct + 5))
170 const warnAt = t.limitPct > 0 ? Math.min(PLAN_WARN_PCT, t.limitPct * 0.9) : PLAN_WARN_PCT
171 const errorAt = t.limitPct > 0 ? t.limitPct : PLAN_ERROR_PCT
172 const paceOn = x.pace === true && x.now !== undefined
173 const paceFor = (w: BudgetWindow): Pace => (paceOn ? paceOf(w, x.now!) : 'none')
174 const hot = (p: Pace): boolean => p === 'amber' || p === 'red'
175 const high = snap.windows.filter(w => w.pct >= warnAt || hot(paceFor(w)))
176 const costLevel: Level = t.limitUsd > 0 ? levelAt(usd, t.limitUsd * USD_WARN_FRACTION, t.limitUsd) : 'ok'
177 if (ctxLevel === 'ok' && high.length === 0 && costLevel === 'ok') return undefined
178
179 const parts: Part[] = [{ text: `Session ~${fmtUsd(usd)}${isEst ? ' est.' : ''}${t.limitUsd > 0 ? ` · limit ${fmtUsdLimit(t.limitUsd)}` : ''}`, level: costLevel }]
180 if (snap.ctxPct !== null) parts.push({ text: `context ${fmtPct(snap.ctxPct)}`, level: ctxLevel })
181 const marks: Part[] = []
182 for (const w of high) {
183 const p = paceFor(w)
184 const level = worse(levelAt(w.pct, warnAt, errorAt), paceLevel(p))
185 parts.push({ text: windowText(w, t.limitPct) + (hot(p) ? ' ▲' : ''), level, isWindow: true })
186 }
187 const out = parts.map(p => ({ ...p, text: p.text + MARK[p.level] }))
188 if (x.ledger !== undefined) {
189 if (x.showDaily !== false && x.ledger.today > 0) marks.push({ text: `Today ${fmtUsd(x.ledger.today)}`, level: 'ok' })
190 if (x.showWeekly !== false && x.ledger.week !== null && x.ledger.week > 0) marks.push({ text: `Week ${fmtUsd(x.ledger.week)}`, level: 'ok' })
191 }
192 return [...out, ...marks]
193}
194
195// ---- the report ----------------------------------------------------------------------------
196
197type ReportInput = {
198 snap: BudgetSnap
199 tally: BudgetTally
200 turns: BudgetTurn[]
201 limitPct: number
202 limitUsd: number
203 now: number
204 /** Optional extras: pace words, and the cost ledger (omitted = old report). */
205 pace?: boolean
206 ledger?: LedgerAtom
207 showDaily?: boolean
208 showWeekly?: boolean
209}
210
211/** One aligned row of the report. */
212const row = (label: string, value: string): string => `${label.padEnd(10)}${value}`
213
214/** One clear phrase for a window that is not on a safe pace; empty otherwise. */
215export function paceWords(w: BudgetWindow, now: number): string {
216 const p = paceOf(w, now)
217 if (p !== 'red' && p !== 'amber') return ''
218 const left = runOutMs(w, now)
219 const t = left === undefined ? undefined : fmtReset(new Date(now + left).toISOString(), now)
220 return p === 'red' ? `▲ on pace to run out${t === undefined ? '' : ` in ~${t}`}` : '▲ running close to the cap by the reset'
221}
222
223function windowRow(w: BudgetWindow, limitPct: number, now: number, pace: boolean): string {
224 const reset = fmtReset(w.resetsAt, now)
225 const words = pace ? paceWords(w, now) : ''
226 return row(windowLabel(w.kind), `${fmtUsed(w.pct)} · ${limitPct > 0 ? `limit ${fmtLimit(limitPct)}` : 'no limit'}${reset === undefined ? '' : ` · resets in ${reset}`}${words === '' ? '' : ` · ${words}`}`)
227}
228
229/** The first line is the headline (it becomes the one-line answer); the rest is the aligned box. */
230export function buildReport(r: ReportInput): string {
231 const { usd, isEst } = costOf(r.snap, r.tally)
232 const cost = `~${fmtUsd(usd)}${isEst ? ' est.' : ''}${r.limitUsd > 0 ? ` · limit ${fmtUsdLimit(r.limitUsd)}` : ''}`
233 const last = r.turns.at(-1)
234 const cached = r.tally.cacheRead + r.tally.cacheWrite
235 const tokensIn = r.tally.input + cached
236 const ctx = r.snap
237 return [
238 `usage-meter · session ${cost} · ${r.tally.turns} ${r.tally.turns === 1 ? 'turn' : 'turns'}`,
239 row('Cost', cost),
240 row('Tokens', `in ${fmtTokens(tokensIn)} · out ${fmtTokens(r.tally.output)}`),
241 row('Cache', cached === 0 ? 'nothing cached yet' : `hit ${fmtPct((r.tally.cacheRead / cached) * 100)} · read ${fmtTokens(r.tally.cacheRead)} · written ${fmtTokens(r.tally.cacheWrite)}`),
242 row('Context', ctx.ctxPct === null ? 'not reported yet' : `${fmtPct(ctx.ctxPct)} full${ctx.ctxWindow === null ? '' : ` of ${fmtTokens(ctx.ctxWindow)}`}`),
243 row('Last turn', last === undefined ? 'none yet' : `${fmtDuration(last.ms)} · ${fmtTokens(last.tokens)} tokens`),
244 row('Longest', r.tally.turns === 0 ? 'none yet' : fmtDuration(r.tally.longestMs)),
245 ...(r.snap.windows.length === 0
246 ? [row('Plan', `API-key use: the limit is in dollars${r.limitPct > 0 ? `; the ${fmtLimit(r.limitPct)} plan limit is idle` : ''}`)]
247 : r.snap.windows.map(w => windowRow(w, r.limitPct, r.now, r.pace === true))),
248 ...(r.ledger !== undefined && r.showDaily !== false ? [row('Today', fmtUsd(r.ledger.today))] : []),
249 ...(r.ledger !== undefined && r.showWeekly !== false && r.ledger.week !== null ? [row('Week', fmtUsd(r.ledger.week))] : []),
250 `Counts start when the mod loads${isEst ? '; cost is estimated from tokens at rough list prices' : ''}.`,
251 ].join('\n')
252}
253
254// ---- the /usage-meter argument -----------------------------------------------------------
255
256export type Word = { kind: 'report' } | { kind: 'ledger' } | { kind: 'ledgerClear' } | { kind: 'set'; pct: number } | { kind: 'setUsd'; usd: number }
257
258/**
259 * The text after `/usage-meter`: nothing for the report; `80` (or `80%`, decimals allowed, above 100
260 * counts as 100) sets the limit in plan points; `$10` sets a dollar limit; `0` or `off` clears both.
261 * Anything else is the report.
262 */
263export function parseArgs(args: string): Word {
264 const a = args.trim().toLowerCase()
265 if (a === 'off') return { kind: 'set', pct: 0 }
266 if (a === 'ledger') return { kind: 'ledger' }
267 if (a === 'ledger clear') return { kind: 'ledgerClear' }
268 const d = /^\$(\d+(?:\.\d+)?)$/.exec(a)
269 if (d?.[1] !== undefined) return { kind: 'setUsd', usd: Number(d[1]) }
270 const m = /^(\d+(?:\.\d+)?)%?$/.exec(a)
271 return m?.[1] === undefined ? { kind: 'report' } : { kind: 'set', pct: Math.min(100, Number(m[1])) }
272}
273
274 /** What "Continue (+50%)" raises a dollar limit to: half of itself more. */
275export const raisedUsd = (limit: number): number => limit * 1.5
276
277/** What "Continue (+10 points)" raises the limit to: ten points more, never above 100. */
278export const raisedLimit = (limit: number): number => Math.min(100, limit + 10)
279
280/** `/usage-meter ledger`: the last 7 days, newest first. */
281export function ledgerReport(l: Ledger, now: number): string {
282 const days = lastDays(l, now, 7)
283 const total = days.reduce((a, d) => a + d.usd, 0)
284 return ['usage-meter · cost ledger, last 7 days', ...days.map((d, i) => i === 0 ? row('Today', `${fmtUsd(d.usd)} (${d.date})`) : `${d.date} ${fmtUsd(d.usd)}`), row('Total', fmtUsd(total))].join('\n')
285}
286hooks/ledger.ts 76 lines1// The per-day cost ledger: pure helpers. Keys are local dates YYYY-MM-DD, values USD. No engine access.
2import type { BudgetWindow } from '../types'
3
4export type Ledger = Record<string, number>
5/** The last cost reading of the running session, so only the growth since is counted. */
6export type LedgerLast = { startedAt: number; usd: number }
7export type LedgerAtom = { today: number; week: number | null }
8
9export const KEEP_DAYS = 60
10const DAY = 86_400_000
11
12const p2 = (n: number): string => String(n).padStart(2, '0')
13
14/** The local calendar date of a time, as YYYY-MM-DD. */
15export function dateKey(ms: number): string {
16 const d = new Date(ms)
17 return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`
18}
19
20/** The date `n` local days before `ms` (calendar arithmetic, so a DST day is still one day). */
21export function daysAgoKey(ms: number, n: number): string {
22 const d = new Date(ms)
23 d.setDate(d.getDate() - n)
24 return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`
25}
26
27const KEY = /^\d{4}-\d{2}-\d{2}$/
28
29/** Whatever the store held, as a clean ledger (bad keys or values dropped). */
30export function cleanLedger(v: unknown): Ledger {
31 const out: Ledger = {}
32 if (v === null || typeof v !== 'object' || Array.isArray(v)) return out
33 for (const [k, n] of Object.entries(v)) if (KEY.test(k) && typeof n === 'number' && Number.isFinite(n) && n >= 0) out[k] = n
34 return out
35}
36
37/** Keeps the last 60 days, today included. */
38export function pruneLedger(l: Ledger, now: number): Ledger {
39 const oldest = daysAgoKey(now, KEEP_DAYS - 1)
40 return Object.fromEntries(Object.entries(l).filter(([k]) => k >= oldest))
41}
42
43/** What one new reading adds: the growth of this session's cost since the last reading, and the new last. */
44export function deltaOf(prev: LedgerLast | null, startedAt: number, usd: number, now: number): { delta: number; last: LedgerLast } {
45 const last = { startedAt, usd }
46 if (prev === null || prev.startedAt !== startedAt) {
47 // The first reading of this session here: its earlier spend counts only if the session began today.
48 return { delta: dateKey(startedAt) === dateKey(now) ? usd : 0, last }
49 }
50 return { delta: usd >= prev.usd ? usd - prev.usd : 0, last } // a falling total is a restarted ledger: counted from here
51}
52
53export function addSpend(l: Ledger, now: number, delta: number): Ledger {
54 const k = dateKey(now)
55 return pruneLedger(delta > 0 ? { ...l, [k]: (l[k] ?? 0) + delta } : l, now)
56}
57
58/** The sum of days from `fromKey` on. */
59const sumFrom = (l: Ledger, fromKey: string): number => Object.entries(l).reduce((s, [k, n]) => (k >= fromKey ? s + n : s), 0)
60
61/** Today, and the week since the weekly window opened (resetsAt minus 7 days, by whole days); null with no readable seven_day window. */
62export function atomOf(l: Ledger, now: number, windows: readonly BudgetWindow[]): LedgerAtom {
63 const w = windows.find(x => x.kind === 'seven_day')
64 const at = w?.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
65 const week = Number.isFinite(at) ? sumFrom(l, dateKey(at - 7 * DAY)) : null
66 return { today: l[dateKey(now)] ?? 0, week }
67}
68
69/** The last `n` days, newest first, zero days included. */
70export function lastDays(l: Ledger, now: number, n: number): { date: string; usd: number }[] {
71 return Array.from({ length: n }, (_, i) => {
72 const date = daysAgoKey(now, i)
73 return { date, usd: l[date] ?? 0 }
74 })
75}
76hooks/reply.ts 33 lines1/**
2 * A `prompt.submit` drop reason must be ONE line: the engine draws a "\n" in it as a stray "?" mark.
3 * `dropWith` splits a multi-line text: the first line becomes the reason, the rest goes to the transcript
4 * as dim notes (`$.ui.log`, never sent to the model), one call per line: `$.ui.log` draws one transcript line, where
5 * a newline also shows as a stray glyph. The note is usually your held prompt, so each line keeps its indentation and
6 * can be copied back.
7 */
8
9// Control characters (tabs, carriage returns, escapes, ...) that would break a one-line reason.
10const CONTROL = /[\u0000-\u001f\u007f-\u009f]+/g
11
12/** A single line with control characters turned into spaces and the ends trimmed. */
13export const oneLine = (text: string): string => text.replace(CONTROL, ' ').replace(/ {2,}/g, ' ').trim()
14
15/** One note line: indentation kept (a tab is two spaces), other control characters turned into a space, the end trimmed. */
16const noteLine = (line: string): string => line.replace(/\t/g, ' ').replace(CONTROL, ' ').trimEnd()
17
18/** `log` is the caller's `(note) => $.ui.log(note)`: the engine refuses `$.ui` handed around as a value. */
19export function dropWith(log: (text: string) => void, text: string): { drop: string } {
20 const lines = text.split(/\r\n|\r|\n/).filter(line => oneLine(line) !== '')
21 const drop = oneLine(lines[0] ?? '') || 'usage-meter'
22 // Called in order, without awaiting: the hook returns its drop at once.
23 for (const line of lines.slice(1)) {
24 try {
25 // A note is a courtesy: a failing log must never undo the drop.
26 void Promise.resolve(log(noteLine(line))).catch(() => undefined)
27 } catch {
28 // keep the drop
29 }
30 }
31 return { drop }
32}
33hooks/pace.ts 47 lines1// Usage pace: will a plan window run out before it resets, at the burn rate so far? Pure, never throws.
2import type { BudgetWindow } from '../types'
3
4export type Pace = 'ok' | 'amber' | 'red' | 'none'
5
6const HOUR = 3_600_000
7/** Window lengths by kind; any other kind has no pace. */
8const LENGTH_MS: Readonly<Record<string, number>> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
9
10/** Too early in a window (under 2%) or too little used (under 10 points) to say anything. */
11const MIN_FRACTION = 0.02
12const MIN_PCT = 10
13const RED_AT = 100
14const AMBER_AT = 90
15
16export type Projection = { projected: number; elapsedMs: number; lengthMs: number }
17
18/** The projected % used at reset, or undefined when the window has no pace or the timestamp is unreadable / outside the window. */
19export function project(w: BudgetWindow, now: number): Projection | undefined {
20 const lengthMs = LENGTH_MS[w.kind]
21 const at = w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt)
22 if (lengthMs === undefined || !Number.isFinite(at) || !Number.isFinite(now) || !Number.isFinite(w.pct)) return undefined
23 const elapsedMs = lengthMs - (at - now)
24 if (elapsedMs <= 0 || elapsedMs > lengthMs) return undefined // not inside the window
25 const fraction = elapsedMs / lengthMs
26 if (fraction < MIN_FRACTION || w.pct < MIN_PCT) return { projected: 0, elapsedMs, lengthMs } // neutral, never an alarm
27 return { projected: w.pct / fraction, elapsedMs, lengthMs }
28}
29
30/** 'red' at 100%+ projected, 'amber' at 90%+, else 'ok'; 'none' where no pace can be computed. */
31export function paceOf(w: BudgetWindow, now: number): Pace {
32 try {
33 const p = project(w, now)
34 if (p === undefined) return 'none'
35 return p.projected >= RED_AT ? 'red' : p.projected >= AMBER_AT ? 'amber' : 'ok'
36 } catch {
37 return 'none'
38 }
39}
40
41/** Milliseconds until the window is used up at the current rate (only meaningful for amber/red); undefined otherwise. */
42export function runOutMs(w: BudgetWindow, now: number): number | undefined {
43 const p = project(w, now)
44 if (p === undefined || p.projected <= 0 || w.pct <= 0) return undefined
45 return Math.max(0, ((100 - w.pct) / w.pct) * p.elapsedMs)
46}
47types/index.d.ts 54 lines1/** Token and time totals of the main session since usage-meter started watching it. */
2export type BudgetTally = {
3 input: number
4 output: number
5 cacheRead: number
6 cacheWrite: number
7 /** Cost estimated from the tokens above at rough list prices; used only where the engine keeps no cost ledger. */
8 estUsd: number
9 /** Main-loop turns finished. */
10 turns: number
11 /** The longest turn so far, ms (kept apart because the per-turn list is capped). */
12 longestMs: number
13}
14
15/** One finished main-loop turn: how long it ran and how many tokens it used. */
16export type BudgetTurn = { ms: number; tokens: number }
17
18/** One plan rate-limit window as the engine reports it. */
19export type BudgetWindow = { kind: string; pct: number; resetsAt?: string }
20
21/** The last usage reading ($.session.usage / session.measure): what the band and the report draw from. */
22export type BudgetSnap = {
23 /** The engine's own session cost in USD; null where it keeps no ledger. */
24 usd: number | null
25 ctxPct: number | null
26 ctxTokens: number | null
27 ctxWindow: number | null
28 windows: BudgetWindow[]
29}
30
31/** The cost ledger mirror: today's spend (USD) and the spend since the weekly (seven_day) window opened; null without one. */
32export type BudgetLedger = { today: number; week: number | null }
33
34declare module 'claude-code' {
35 interface PluginState {
36 'usage-meter': {
37 tally: BudgetTally
38 /** The newest turns, capped (see MAX_TURNS). */
39 turns: BudgetTurn[]
40 /** When the running main-loop turn began, ms; null between turns. */
41 turnStart: number | null
42 /** The plan-window limit (percent used) set by `/usage-meter N` for this session; null = use the setting. */
43 limit: number | null
44 /** The dollar limit set by `/usage-meter $N` for this session; null = use the setting. */
45 limitUsd: number | null
46 snap: BudgetSnap
47 /** Per-day cost ledger mirror read by the moddeck side bar; the days themselves live in $.store 'ledger'. */
48 ledger: BudgetLedger
49 /** The last cost reading of this session ({ startedAt, usd }) so only growth is added to the ledger; internal. */
50 lastCost: { startedAt: number; usd: number } | null
51 }
52 }
53}
54