SLOPSHOPPER

usage-meter

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…

newbandcommandtoastprompt
★ 1v0.5.0MITupdated 2026-10-14samuelbfernandes/moddeck/mods/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› 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 › /usage-meter ⎿ usage-meter: usage-meter · session ~$0.42 · 1 turn ⎿ usage-meter: Cost ~$0.42 ⎿ usage-meter: Tokens in 97k · out 1.5k ⎿ usage-meter: Cache hit 95% · read 91k · written 4.3k ⎿ usage-meter: Context 49% full of 200k ⎿ usage-meter: Last turn 42s · 99k tokens ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

usage-meter

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.

What it does

  • Usage line above the prompt box (terminal and desktop). Hidden normally. It appears only when the context window reaches 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 !.
  • Usage limit, two ways. 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.
  • Usage pace (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.
  • Cost ledger. A small per-day total of spend across sessions (local dates, last 60 days) is kept in the mod's own store. Only the growth of the engine's session cost since the last reading is added, so a resumed session's old spend is not counted again (a session that began before today contributes only what it spends from now on). The line and the report show 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 }.

Use

  • /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).
  • If your account reports no plan window (e.g. API-key use), the report says "API-key use: the limit is in dollars"; a percent limit cannot be enforced then. Prompts are never refused.

Turn off

/plugin disable usage-meter, or set enabled to false.

Settings

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).

Limits

  • Cost is the engine's own session total when it reports one; otherwise it is estimated from token counts at rough built-in list prices and labelled "est." (cache writes at the 5-minute rate, unknown models priced like Sonnet). Prices drift.
  • Tokens, cache ratio and turn times count from when the mod loaded, and restart after /clear. The /usage-meter N override is not saved.
  • The limit is checked when you send a prompt, so one long turn can overshoot it. Plan windows only exist if your account reports them.
  • The week total is whole days (the window's opening day counts in full), and several sessions writing the ledger at the same instant may lose one reading. The pace assumes use is even across the window.
  • If a check fails for any reason, your prompt is sent as typed.
  • Verified against mocks only (82 tests, terminal and desktop). Not run in a live session, and nothing has been run on Windows.
Source 6 files
hooks/register.tsx 361 lines
1// 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}
361
hooks/lib.ts 286 lines
1// 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}
286
hooks/ledger.ts 76 lines
1// 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}
76
hooks/reply.ts 33 lines
1/**
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}
33
hooks/pace.ts 47 lines
1// 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}
47
types/index.d.ts 54 lines
1/** 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