SLOPSHOPPER

cost-ledger

Tracks what Claude Code costs — session, calendar-week and calendar-month totals with the month's pace and week-over-week change, a /costs pane with a spend…

newpanebandguardcommandtoast
★ 2v0.5.0MITupdated 2026-10-06bobtat/claude-plugins/plugins/cost-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-ledger
│ ┃ Costs ✕ › fix the failing auth test and add an audit log call │ ┃ Session $0.42 │ ┃ Today $0.00 ⏺ Read(src/auth.ts) │ ┃ This week $0.00 since 2025-10-06 ⎿ Read 6 lines │ ┃ This month $0.00 on pace for $0.00 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ p: ◀ October 2025 ⏺ Bash(bun test) │ ┃ Mo Tu We Th Fr Sa Su ⎿ 3 pass, 1 fail │ ┃ 1 2 3 4 5 │ ┃ 6 7 8 9 10 11 12 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 13 14 15 16 17 18 19 │ ┃ 20 21 22 23 24 25 26 ✻ Worked for 42s · done 4:20 PM │ ┃ 27 28 29 30 31 │ ┃ less more $0.00 total › /costs │ ┃ Shade by s: total spend ⎿ cost-ledger: Costs pane opened. │ ┃ │ ┃ Prompt cache, October 2025 (estimated at │ ┃ list prices) │ ┃ No requests recorded. │ ┃ │ ┃ Most expensive prompts, October 2025 │ ┃ (estimated, subagents included) │ ┃ None recorded. │ ┃ │ ┃ What filled the context, October 2025 (tool │ ┃ results, ≈ tokens) $0.42 session · $0.00 week · $0.00 month ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cost-ledger: $0.42 session · $0.00 week · $0.00 month

Draws

Band
$0.42 session · $0.00 week · $0.00 month
Pane · Costs
Session $0.42 Today $0.00 This week $0.00 since 2025-10-06 This month $0.00 on pace for $0.00 p: ◀ October 2025 Mo Tu We Th Fr Sa Su 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 less more $0.00 total Shade by s: total spend Prompt cache, October 2025 (estimated at list prices) No requests recorded. Most expensive prompts, October 2025 (estimated, subagents included) None recorded. What filled the context, October 2025 (tool results, ≈ tokens) tool calls tokens write re-read Read 1 49 $0.00 $0.00 Grep 1 8 $0.00 $0.00 Edit 1 12 $0.00 $0.00 Write 2 26 $0.00 $0.00 Bash 3 76 $0.00 $0.00 By project, October 2025 No spend recorded. Daily, October 2025 No spend recorded.
README

cost-ledger

A Claude Code mod that keeps a running ledger of what Claude Code costs: this session, this calendar week (Monday to Sunday) and this calendar month, in local time. It also makes prompt-cache spend visible, since on long agentic sessions cache_read and cache_write are usually most of the bill.

Unlike the other plugins in this marketplace, cost-ledger is code: a TypeScript hooks module that Claude Code loads and runs inside the session, not skills or commands for Claude to read.

Installation

/plugin marketplace add bobtat/claude-plugins
/plugin install cost-ledger@bobtat-plugins

What it shows

WhereWhat
Status line and a band above the prompt$1.84 session · $23.10 week · $96.40 month · ctx 150k ≈$0.03/step · cache warm 3m
/costs paneTotals with budget progress, the change against the same point last week and the month's pace (with the day a monthly budget would be reached), a heat-map calendar per month (shade by total spend or by cache writes, p/n to change month, s to switch shading), the month's prompt-cache breakdown, the subagent share, the most expensive prompts, what filled the context, spend by project, and daily bars
/costs exportWrites ~/claude-costs/claude-costs-<date>.csv: one row per day and project with the total, tokens and estimated cost per category, and subagent cost
/costs doctorChecks the assumptions the totals rest on (see below)
ToastsBefore a prompt is sent once the cache has likely expired; before a model switch that drops a warm cache; after one request writes a large amount to the cache; after a prompt whose cost passes a threshold; at 80% and 100% of a budget

All warnings are informational: nothing is ever blocked.

Where the numbers come from

 session.measure ── cost.usd (Claude Code's running total) ──► spend per session and day
 turn.step ──────── usage per request (tokens, model) ───────► priced per category
                                                                  │
                    $.store: one entry per session, ◄─────────────┘
                    archived per project after 60 days, kept 400 days
                                   │
                                   ▼
                status line · band · /costs pane · CSV export
  • Totals (session, week, month, calendar, budgets) are Claude Code's own figure, the one /cost shows. It is list price unless an administrator has set the managed modelPricing setting.
  • The category breakdown (input, output, cache write, cache read) is estimated by the mod from each request's token counts, at Anthropic list prices or at the rates you give it. Token counts and the cache-hit percentage are exact; the dollars are estimates.
  • The cost of a prompt is the estimated cost of every request made between the prompt and its answer, its subagents' included, with the first 80 characters of the prompt. The month's ten most expensive are kept. Prompt text stays in the local store; it is not exported.
  • What filled the context measures each tool result as the model reads it, at roughly four characters a token, then charges it a cache write on the first request that sends it and a cache read on every request after, until /clear or /compact drops it. A large file read early in a long turn shows up here as the re-reads it caused. The tokens are approximate, so the dollars are a guide to where the cache spend comes from rather than a bill.
  • Pace extrapolates the month's spend at its average daily rate so far, counting at least one day. Week over week compares Monday to today with Monday to the same weekday last week.
  • Only sessions that ran with the mod enabled are counted. There is no backfill from older transcripts.

Checking the setup

/costs doctor reports, each line marked ✓, ⚠ or ℹ:

  • Time zone: whether the mod's local time matches the host clock (Get-Date on Windows, date elsewhere), since days, weeks and months are split by it.
  • Store file: whether a write reaches the store file on disk at once, whether this session's view of the store matches the file (if it does not, another session's writes may be getting lost), and how close the file is to the 4 MiB limit.
  • Another install: a store file left by a differently installed copy of the mod. Each install source (marketplace, --plugin-dir, a session's mods folder) has its own store, so history recorded by one is not in another's totals.
  • Claude Code pricing: whether Claude Code prices at your organisation's managed rates or at list price, once it has said so, which it does when you switch models.
  • Cache lifetime, baseline, rates and models without a rate.

It cannot check the /resume accounting: for that, compare /costs before and after resuming a session whose spend you know.

Settings

Set in /config under cost-ledger.

SettingDefaultMeaning
billingapisubscription labels figures as API-equivalent and adds the 5-hour and 7-day rate-limit percentages
statusLine, bandonShow the summary in the status line and in the band
ratesemptyJSON of USD per million tokens by model family, overriding list prices for some or all fields, for example {"opus-5-5": {"input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite5m": 5, "cacheWrite1h": 8}}. A family matches any model id that contains it and does not continue with another version number, so Bedrock ids such as us.anthropic.claude-opus-5-5-v1:0 resolve. A family with no list price needs input, output and cacheRead
cacheTtlautoPrompt-cache lifetime used for the countdown, the idle warning and write pricing. auto uses what Claude Code reports around a model switch or resume, otherwise 5 minutes. Set 1h if you know your setup uses the one-hour cache
largeWriteTokens50000Toast when one request writes at least this many tokens to the cache; 0 turns it off
contextNudgeTokens150000Suggest /compact once the context reaches this size; 0 turns it off
dailyBudget, weeklyBudget, monthlyBudget0USD; toast at 80% and 100%; 0 turns each off
expensiveTurnUsd2Toast when one prompt, its subagents included, costs at least this much (estimated); 0 turns it off

Models with no list price in the bundled reference (Opus 4.5, Opus 4.1, Sonnet 4.5) are not guessed at: their tokens are reported as unpriced in the pane until rates names them.

Known limits

  • Week and day boundaries use the local time of the process running Claude Code.
  • The store is one JSON file shared by every Claude Code process on the machine. Two sessions writing at the same moment have not been tested for lost updates, and two sessions can both show the same budget toast.
  • After an in-process /resume, the mod takes Claude Code's running cost at that moment as the new baseline. If Claude Code restores the resumed session's earlier cost only after that point, the earlier cost would be counted again. This has been reasoned from the API documentation and covered by tests, not yet observed in a live resume.
  • Spend from a subagent that finishes after the session's last turn is recorded when the session ends; a process that is killed outright loses it.

Development

The module is hooks/register.tsx. Date and summary logic is in hooks/ledger.ts, pricing in hooks/pricing.ts, and the per-prompt, context and pace arithmetic in hooks/insights.ts; all three are free of engine calls so they can be tested directly.

claude plugin validate plugins/cost-ledger
claude plugin test plugins/cost-ledger

tsc -p plugins/cost-ledger type-checks it once Claude Code has loaded the mod from the folder (for example with claude --plugin-dir plugins/cost-ledger), which writes the API declarations to .claude-plugin/types/. That folder is regenerated on every load and is not committed.

License

MIT

Source 5 files
hooks/register.tsx 953 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Breakdown, Context, RateLimit, ToolUse, Totals } from '../types'
5import {
6  addStepToTurn,
7  addToolResult,
8  budgetReachDay,
9  chargesAsUse,
10  chargeStep,
11  emptyLoop,
12  estimateTokens,
13  finishTurn,
14  monthPace,
15  startTurn,
16  toolLabel,
17  topTools,
18  turnsIn,
19  turnUsd,
20  weekOverWeek,
21} from './insights'
22import {
23  addSpend,
24  addTools,
25  addTurn,
26  addUsage,
27  applyTools,
28  applyTurn,
29  applyUsage,
30  budgetAlert,
31  cacheMinutesLeft,
32  dayKey,
33  daysIn,
34  exportCsv,
35  formatTokens,
36  formatUsd,
37  heatLevel,
38  mergeEntries,
39  monthView,
40  OLDEST_MONTH_OFFSET,
41  projectName,
42  pruneDays,
43  shouldArchive,
44  spendSince,
45  summarize,
46  sumMonth,
47  UNKNOWN_PROJECT,
48  weekStartKey,
49} from './ledger'
50import type { SessionEntry } from './ledger'
51import {
52  addBreakdown,
53  CATEGORIES,
54  hitRate,
55  priceUsage,
56  rateTable,
57  ratesFor,
58  totalUsd,
59  writeRate,
60} from './pricing'
61import type { CacheTtl, RateTable, Usage } from './pricing'
62
63const PANE = 'cost-ledger'
64const SESSION_PREFIX = 'session:'
65const ARCHIVE_PREFIX = 'archive:'
66const LEARNED_TTL_KEY = 'learnedCacheTtl'
67const LEARNED_PRICING_KEY = 'learnedPricing'
68const UNPRICED_MODELS_KEY = 'unpricedModels'
69const PROBE_KEY = 'doctorProbe'
70const STORE_LIMIT_BYTES = 4 * 1024 * 1024
71const SHOWN_TURNS = 10
72const SHOWN_TOOLS = 8
73const BUDGET_PREFIX = 'budget:'
74const TICK_MS = 30_000
75const SHOWN_PROJECTS = 8
76const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
77const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
78const HEAT = ['', '#0e4429', '#006d32', '#26a641', '#39d353'] as const
79const RATE_LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
80const CATEGORY_NAMES = { input: 'Input', output: 'Output', cacheWrite: 'Cache write', cacheRead: 'Cache read' }
81
82const totals = atom({ plugin: 'cost-ledger', key: 'totals' } as const, null)
83const lastUsd = atom({ plugin: 'cost-ledger', key: 'lastUsd' } as const, null)
84const monthOffset = atom({ plugin: 'cost-ledger', key: 'monthOffset' } as const, 0)
85const context = atom({ plugin: 'cost-ledger', key: 'context' } as const, null)
86const shadeBy = atom({ plugin: 'cost-ledger', key: 'shadeBy' } as const, 'total')
87const tick = atom({ plugin: 'cost-ledger', key: 'tick' } as const, 0)
88const activeTurn = atom({ plugin: 'cost-ledger', key: 'activeTurn' } as const, null)
89const turnPrompts = atom({ plugin: 'cost-ledger', key: 'turnPrompts' } as const, {})
90const loops = atom({ plugin: 'cost-ledger', key: 'loops' } as const, {})
91
92type Settings = {
93  isSubscription: boolean
94  hasStatusLine: boolean
95  table: RateTable
96  hasCustomRates: boolean
97  ttlSetting: string
98  largeWriteTokens: number
99  contextNudgeTokens: number
100  budgets: { day: number; week: number; month: number }
101  expensiveTurnUsd: number
102}
103
104function readSettings(options: PluginOptions): Settings {
105  const rates = typeof options.rates === 'string' ? options.rates : ''
106  return {
107    isSubscription: options.billing === 'subscription',
108    hasStatusLine: options.statusLine === true,
109    table: rateTable(rates),
110    hasCustomRates: rates.trim() !== '',
111    ttlSetting: typeof options.cacheTtl === 'string' ? options.cacheTtl : 'auto',
112    largeWriteTokens: typeof options.largeWriteTokens === 'number' ? options.largeWriteTokens : 0,
113    contextNudgeTokens: typeof options.contextNudgeTokens === 'number' ? options.contextNudgeTokens : 0,
114    budgets: {
115      day: typeof options.dailyBudget === 'number' ? options.dailyBudget : 0,
116      week: typeof options.weeklyBudget === 'number' ? options.weeklyBudget : 0,
117      month: typeof options.monthlyBudget === 'number' ? options.monthlyBudget : 0,
118    },
119    expensiveTurnUsd: typeof options.expensiveTurnUsd === 'number' ? options.expensiveTurnUsd : 0,
120  }
121}
122
123async function loadEntries($: EngineInterface): Promise<[string, SessionEntry][]> {
124  const keys = (await $.store.keys()).filter(key => key.startsWith(SESSION_PREFIX) || key.startsWith(ARCHIVE_PREFIX))
125  return Promise.all(
126    keys.map(async key => [key, (await $.store.get(key)) as SessionEntry] as [string, SessionEntry]),
127  )
128}
129
130async function cacheTtl($: EngineInterface, settings: Settings): Promise<CacheTtl> {
131  if (settings.ttlSetting === '5m' || settings.ttlSetting === '1h') return settings.ttlSetting
132  const learned = await $.store.get(LEARNED_TTL_KEY)
133  return learned === '1h' ? '1h' : '5m'
134}
135
136function rateLimitText(limits: readonly RateLimit[]): string {
137  return limits.map(l => `${RATE_LIMIT_NAMES[l.kind] ?? l.kind} ${Math.round(l.percentUsed)}%`).join(' · ')
138}
139
140function perStepText(ctx: Context, settings: Settings): string {
141  const rates = ratesFor(settings.table, ctx.model)
142  return rates ? ` ≈${formatUsd((ctx.tokens * rates.cacheRead) / 1_000_000)}/step` : ''
143}
144
145function nudgeText(ctx: Context, settings: Settings): string {
146  return settings.contextNudgeTokens > 0 && ctx.tokens >= settings.contextNudgeTokens ? ', consider /compact' : ''
147}
148
149function cacheText(ctx: Context, ttl: CacheTtl, now: number): string {
150  const left = cacheMinutesLeft(ctx.at, TTL_MS[ttl], now)
151  return left > 0 ? ` · cache warm ${left}m` : ' · cache cold'
152}
153
154// The context segment of the status line and band; empty before the first
155// main-thread response of the session.
156async function contextLine($: EngineInterface, settings: Settings, ctx: Context | null): Promise<string> {
157  if (ctx === null || ctx.tokens === 0) return ''
158  const ttl = await cacheTtl($, settings)
159  const cache = cacheText(ctx, ttl, await $.clock.now())
160  return ` · ctx ${formatTokens(ctx.tokens)}${perStepText(ctx, settings)}${cache}${nudgeText(ctx, settings)}`
161}
162
163function summaryLine(t: Totals, settings: Settings, contextPart: string): string {
164  const qualifier = settings.isSubscription ? ' (API-equiv.)' : ''
165  const costs = `${formatUsd(t.session)} session · ${formatUsd(t.week)} week · ${formatUsd(t.month)} month${qualifier}`
166  const limits = settings.isSubscription && t.rateLimits.length > 0 ? ` · ${rateLimitText(t.rateLimits)}` : ''
167  return costs + limits + contextPart
168}
169
170async function updateStatus($: EngineInterface, settings: Settings) {
171  if (!settings.hasStatusLine) {
172    $.ui.status(undefined)
173    return
174  }
175  const t = await read($, totals)
176  if (t === null) return
177  const ctx = await read($, context)
178  $.ui.status(summaryLine(t, settings, await contextLine($, settings, ctx)))
179}
180
181async function onTick($: EngineInterface, settings: Settings) {
182  const now = await $.clock.now()
183  await update($, tick, () => now)
184  await updateStatus($, settings)
185}
186
187async function checkBudgets($: EngineInterface, settings: Settings, t: Totals) {
188  const today = dayKey(await $.clock.now())
189  const periods = [
190    { name: 'Daily', key: `day:${today}`, spent: t.today, limit: settings.budgets.day },
191    { name: 'Weekly', key: `week:${t.weekStart}`, spent: t.week, limit: settings.budgets.week },
192    { name: 'Monthly', key: `month:${today.slice(0, 7)}`, spent: t.month, limit: settings.budgets.month },
193  ]
194  for (const period of periods) {
195    const storeKey = `${BUDGET_PREFIX}${period.key}:${period.limit}`
196    const announced = Number((await $.store.get(storeKey)) ?? 0)
197    const reached = budgetAlert(period.spent, period.limit, announced)
198    if (reached === null) continue
199
200    await $.store.set(storeKey, reached)
201    const verb = reached >= 1 ? 'reached' : `at ${Math.round(reached * 100)}%`
202    $.ui.toast(`${period.name} budget ${verb}: ${formatUsd(period.spent)} of ${formatUsd(period.limit)}.`, {
203      timeoutMs: 10_000,
204    })
205  }
206}
207
208async function pruneBudgetKeys($: EngineInterface, now: number) {
209  const today = dayKey(now)
210  const current = [`day:${today}:`, `week:${weekStartKey(now)}:`, `month:${today.slice(0, 7)}:`]
211  const isCurrent = (period: string) => current.some(prefix => period.startsWith(prefix))
212  for (const key of await $.store.keys()) {
213    if (key.startsWith(BUDGET_PREFIX) && !isCurrent(key.slice(BUDGET_PREFIX.length))) await $.store.delete(key)
214  }
215}
216
217async function writeExport($: EngineInterface): Promise<string> {
218  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
219  if (home === undefined) return 'Could not find a home folder to write the export to.'
220
221  const csv = exportCsv((await loadEntries($)).map(([, entry]) => entry))
222  const path = `${home}/claude-costs/claude-costs-${dayKey(await $.clock.now())}.csv`
223  await $.fs.write(path, csv)
224  return `Wrote ${csv.trimEnd().split('\n').length - 1} rows to ${path}`
225}
226
227// Module scope on purpose: one notice per load of the mod is enough.
228let hasWarnedSaveFailure = false
229
230async function saveEntry($: EngineInterface, key: string, entry: SessionEntry): Promise<boolean> {
231  try {
232    await $.store.set(key, entry)
233    return true
234  } catch (error) {
235    if (!hasWarnedSaveFailure) {
236      hasWarnedSaveFailure = true
237      $.ui.toast(`cost-ledger could not save spend, so totals will fall behind: ${String(error)}`, {
238        timeoutMs: 15_000,
239      })
240    }
241    return false
242  }
243}
244
245// Sessions untouched for 60 days are folded into one archive entry per
246// project, and days past retention are dropped, which keeps the store far
247// below its 4 MiB limit however many sessions run.
248async function compactStore($: EngineInterface, now: number) {
249  for (const [key, entry] of await loadEntries($)) {
250    if (!key.startsWith(SESSION_PREFIX) || !shouldArchive(entry, now)) continue
251    const archiveKey = ARCHIVE_PREFIX + (entry.project ?? UNKNOWN_PROJECT)
252    const archive = (await $.store.get(archiveKey)) as SessionEntry | undefined
253    if (await saveEntry($, archiveKey, mergeEntries(archive, entry))) await $.store.delete(key)
254  }
255  for (const [key, entry] of await loadEntries($)) {
256    const pruned = pruneDays(entry, now)
257    const dayCount = (e: SessionEntry) =>
258      [e.days, e.cache, e.subagentCache].reduce((sum, days) => sum + Object.keys(days ?? {}).length, 0)
259    if (pruned === undefined) await $.store.delete(key)
260    else if (dayCount(pruned) !== dayCount(entry)) await saveEntry($, key, pruned)
261  }
262}
263
264async function rebaseline($: EngineInterface) {
265  const usage = await $.session.usage()
266  const usd = usage.cost?.usd ?? 0
267  await update($, lastUsd, () => usd)
268}
269
270async function refresh(
271  $: EngineInterface,
272  settings: Settings,
273  sessionUsd?: number,
274  rateLimits?: readonly RateLimit[],
275) {
276  const now = await $.clock.now()
277  const previous = await read($, totals)
278  const entries = (await loadEntries($)).map(([, entry]) => entry)
279  const next: Totals = {
280    session: sessionUsd ?? previous?.session ?? 0,
281    ...summarize(entries, now),
282    rateLimits: [...(rateLimits ?? previous?.rateLimits ?? [])],
283  }
284  await update($, totals, () => next)
285  await updateStatus($, settings)
286  await checkBudgets($, settings, next)
287}
288
289async function recordSpend($: EngineInterface, usd: number) {
290  const spent = spendSince(await read($, lastUsd), usd)
291  if (spent > 0) {
292    const key = SESSION_PREFIX + (await $.session.id())
293    const entry = (await $.store.get(key)) as SessionEntry | undefined
294    // The baseline moves only once the spend is saved, so a failed save is
295    // counted again by the next measurement instead of being lost.
296    if (!(await saveEntry($, key, addSpend(entry, await $.clock.now(), spent, await $.session.root())))) return
297  }
298  await update($, lastUsd, () => usd)
299}
300
301type Step = { turnId: string; agentId?: string; startedAt: number }
302
303async function recordStep($: EngineInterface, settings: Settings, usage: Usage & { model: string }, step: Step) {
304  const isMainThread = step.agentId === undefined
305  const ttl = await cacheTtl($, settings)
306  const rates = ratesFor(settings.table, usage.model)
307  const priced = priceUsage(usage, rates, ttl)
308  if (rates === undefined) await noteUnpricedModel($, usage.model)
309
310  const loopId = step.agentId ?? 'main'
311  const loopsNow = await read($, loops)
312  const charged = chargeStep(
313    loopsNow[loopId] ?? emptyLoop(),
314    (rates?.cacheRead ?? 0) / 1_000_000,
315    (rates ? writeRate(rates, ttl) : 0) / 1_000_000,
316  )
317  await update($, loops, all => ({ ...all, [loopId]: charged.loop }))
318  const toolCosts = chargesAsUse(charged.charges)
319
320  const now = await $.clock.now()
321  const project = await $.session.root()
322  const key = SESSION_PREFIX + (await $.session.id())
323  const entry = (await $.store.get(key)) as SessionEntry | undefined
324  await saveEntry($, key, addTools(addUsage(entry, now, priced, project, !isMainThread), now, toolCosts))
325  await update($, totals, t => (t === null ? t : applyTools(t, now, toolCosts)))
326  await addToActiveTurn($, step, priced, isMainThread, now)
327  await update($, totals, t => (t === null ? t : applyUsage(t, now, priced, project, !isMainThread)))
328
329  if (isMainThread) {
330    const tokens =
331      usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
332    await update($, context, () => ({ tokens, model: usage.model, at: step.startedAt }))
333  }
334
335  const written = usage.cache_creation_input_tokens
336  if (settings.largeWriteTokens > 0 && written >= settings.largeWriteTokens) {
337    const cost = rates ? `≈ ${formatUsd(priced.usd.cacheWrite)}` : '(no rate for this model)'
338    $.ui.toast(`One request wrote ${formatTokens(written)} tokens to the prompt cache ${cost} on ${usage.model}.`, {
339      timeoutMs: 8000,
340    })
341  }
342
343  await updateStatus($, settings)
344}
345
346async function noteUnpricedModel($: EngineInterface, model: string) {
347  const seen = ((await $.store.get(UNPRICED_MODELS_KEY)) as string[] | undefined) ?? []
348  if (!seen.includes(model)) await $.store.set(UNPRICED_MODELS_KEY, [...seen, model].slice(-20))
349}
350
351// A turn becomes the prompt's record at its first main-thread request, since
352// turn.start does not say which loop it belongs to. Subagent requests made
353// while it runs are charged to it.
354async function addToActiveTurn(
355  $: EngineInterface,
356  step: Step,
357  priced: Breakdown,
358  isMainThread: boolean,
359  now: number,
360) {
361  const current = await read($, activeTurn)
362  if (isMainThread && current?.turnId !== step.turnId) {
363    const prompt = (await read($, turnPrompts))[step.turnId]
364    const started = startTurn(step.turnId, prompt?.text || '(continuation)', prompt?.at ?? now)
365    await update($, activeTurn, () => addStepToTurn(started, priced, false))
366    return
367  }
368  if (current !== null) await update($, activeTurn, turn => (turn === null ? turn : addStepToTurn(turn, priced, !isMainThread)))
369}
370
371async function finishActiveTurn($: EngineInterface, settings: Settings, turnId: string) {
372  const turn = await read($, activeTurn)
373  await update($, turnPrompts, prompts => {
374    const { [turnId]: _done, ...rest } = prompts
375    return rest
376  })
377  if (turn === null || turn.turnId !== turnId) return
378  await update($, activeTurn, () => null)
379
380  const now = await $.clock.now()
381  const record = finishTurn(turn, await $.session.root())
382  const key = SESSION_PREFIX + (await $.session.id())
383  const entry = (await $.store.get(key)) as SessionEntry | undefined
384  await saveEntry($, key, addTurn(entry, now, record))
385  await update($, totals, t => (t === null ? t : applyTurn(t, record)))
386
387  const usd = turnUsd(record)
388  if (settings.expensiveTurnUsd > 0 && usd >= settings.expensiveTurnUsd) {
389    const detail = `${record.steps} requests${record.subagentSteps > 0 ? ` + ${record.subagentSteps} by subagents` : ''}`
390    $.ui.toast(`That prompt cost ≈ ${formatUsd(usd)} (${detail}): “${record.prompt}”`, { timeoutMs: 10_000 })
391  }
392}
393
394async function recordToolResult($: EngineInterface, tool: string, agentId: string | undefined, text: string) {
395  const tokens = estimateTokens(text)
396  const loopId = agentId ?? 'main'
397  await update($, loops, all => ({ ...all, [loopId]: addToolResult(all[loopId], tool, tokens) }))
398
399  const now = await $.clock.now()
400  const use: Record<string, ToolUse> = { [tool]: { calls: 1, tokens, writeUsd: 0, rereadUsd: 0 } }
401  const key = SESSION_PREFIX + (await $.session.id())
402  const entry = (await $.store.get(key)) as SessionEntry | undefined
403  await saveEntry($, key, addTools(entry, now, use))
404  await update($, totals, t => (t === null ? t : applyTools(t, now, use)))
405}
406
407const localStamp = (ms: number) => {
408  const d = new Date(ms)
409  const pad = (n: number) => String(n).padStart(2, '0')
410  const offset = -d.getTimezoneOffset()
411  const sign = offset >= 0 ? '+' : '-'
412  const hhmm = `${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`
413  return `${dayKey(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())} ${sign}${hhmm}`
414}
415
416async function hostLocalTime($: EngineInterface): Promise<string | undefined> {
417  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
418  const argv = isWindows
419    ? ['powershell', '-NoProfile', '-Command', "Get-Date -Format 'yyyy-MM-dd HH:mm zzz'"]
420    : ['date', '+%Y-%m-%d %H:%M %z']
421  try {
422    const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 10_000 })
423    return exitCode === 0 ? stdout.trim().replace(/([+-]\d{2})(\d{2})$/, '$1:$2') : undefined
424  } catch {
425    return undefined
426  }
427}
428
429async function readOwnStoreFiles($: EngineInterface): Promise<{ path: string; text: string }[] | undefined> {
430  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
431  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home && `${home}/.claude`)
432  if (!configDir) return undefined
433  const dir = `${configDir}/plugins/store`
434  try {
435    const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.startsWith(`${$.plugin.name}_`))
436    return Promise.all(files.map(async f => ({ path: `${dir}/${f.name}`, text: (await $.fs.read(`${dir}/${f.name}`)) as string })))
437  } catch {
438    return undefined
439  }
440}
441
442function storeKeysOf(text: string): string[] | undefined {
443  try {
444    const parsed: unknown = JSON.parse(text)
445    return typeof parsed === 'object' && parsed !== null ? Object.keys(parsed).sort() : undefined
446  } catch {
447    return undefined
448  }
449}
450
451// Each check reports what it saw; nothing here changes the ledger except the
452// probe key, which is removed again.
453async function runDoctor($: EngineInterface, settings: Settings): Promise<string> {
454  const lines = ['cost-ledger self-check', '']
455  const say = (isOk: boolean | null, label: string, detail: string) =>
456    lines.push(`${isOk === null ? 'ℹ' : isOk ? '✓' : '⚠'} ${label}: ${detail}`)
457  const now = await $.clock.now()
458
459  const mine = localStamp(now)
460  const host = await hostLocalTime($)
461  if (host === undefined) say(null, 'Time zone', `the mod sees ${mine}; the host's clock could not be read to compare`)
462  else {
463    const isSame = host.slice(0, 13) === mine.slice(0, 13) && host.slice(-6) === mine.slice(-6)
464    say(isSame, 'Time zone', isSame ? `day boundaries follow local time (${mine})` : `the mod sees ${mine} but the host says ${host}, so days are split at the wrong hour`)
465  }
466
467  const probe = `${now}-${Math.random().toString(36).slice(2)}`
468  await $.store.set(PROBE_KEY, probe)
469  try {
470    const files = await readOwnStoreFiles($)
471    const own = files?.find(f => f.text.includes(probe))
472    const ownKeys = own && storeKeysOf(own.text)
473    if (own === undefined || ownKeys === undefined) {
474      say(false, 'Store file', 'a write could not be found on disk, so the checks that compare the file were skipped')
475    } else {
476      say(true, 'Store file', `writes reach disk at once (${own.path})`)
477      const inView = [...(await $.store.keys())].sort()
478      const isSameView = ownKeys.join('\n') === inView.join('\n')
479      say(
480        isSameView,
481        'Other sessions',
482        isSameView
483          ? 'this session sees exactly what is on disk, so writes from other sessions are not being overwritten'
484          : `this session sees ${inView.length} keys and the file has ${ownKeys.length}; another session's writes may be lost`,
485      )
486      const percent = Math.round((own.text.length / STORE_LIMIT_BYTES) * 100)
487      say(percent < 75, 'Store size', `${Math.round(own.text.length / 1024)} KiB, ${percent}% of the 4 MiB limit`)
488    }
489    for (const other of files?.filter(f => f !== own) ?? []) {
490      const sessions = storeKeysOf(other.text)?.filter(k => k.startsWith(SESSION_PREFIX)).length
491      say(
492        null,
493        'Another install',
494        sessions === undefined
495          ? `${other.path} could not be read`
496          : `${other.path} holds ${sessions} sessions recorded by a differently installed copy; they are not in these totals`,
497      )
498    }
499  } finally {
500    await $.store.delete(PROBE_KEY)
501  }
502
503  const pricing = await $.store.get(LEARNED_PRICING_KEY)
504  say(
505    null,
506    'Claude Code pricing',
507    pricing === 'configured'
508      ? "your organisation's managed modelPricing, so the totals follow your rates"
509      : pricing === 'catalog'
510        ? 'Anthropic list prices; on Bedrock the totals may differ from your bill'
511        : 'not reported yet; Claude Code reports it when you switch models with /model',
512  )
513
514  const learnedTtl = await $.store.get(LEARNED_TTL_KEY)
515  say(
516    null,
517    'Cache lifetime',
518    `using ${await cacheTtl($, settings)} (setting: ${settings.ttlSetting}; reported by Claude Code: ${typeof learnedTtl === 'string' ? learnedTtl : 'not yet'})`,
519  )
520
521  const usd = (await $.session.usage()).cost?.usd ?? 0
522  const recorded = await read($, lastUsd)
523  if (recorded === null) say(false, 'Baseline', 'not set; spend is recorded from the next measurement')
524  else if (recorded > usd + 0.005) say(false, 'Baseline', `recorded up to ${formatUsd(recorded)}, above the session's ${formatUsd(usd)}`)
525  else say(true, 'Baseline', `session at ${formatUsd(usd)}, recorded up to ${formatUsd(recorded)}${usd - recorded >= 0.005 ? '; the rest is recorded after the next turn' : ''}`)
526
527  if (settings.table.error) say(false, 'Rates', settings.table.error)
528  const unpriced = ((await $.store.get(UNPRICED_MODELS_KEY)) as string[] | undefined) ?? []
529  say(unpriced.length === 0, 'Models', unpriced.length === 0 ? 'every model seen has a rate' : `no rate for ${unpriced.join(', ')}; add them to the rates setting`)
530
531  say(null, 'Resume', 'cannot be checked from here: compare /costs before and after a /resume of a session with known spend')
532  return lines.join('\n')
533}
534
535async function warnIfCacheExpired($: EngineInterface, settings: Settings) {
536  const ctx = await read($, context)
537  if (ctx === null || ctx.tokens === 0) return
538
539  const ttl = await cacheTtl($, settings)
540  const idleMs = (await $.clock.now()) - ctx.at
541  if (idleMs <= TTL_MS[ttl]) return
542
543  const rates = ratesFor(settings.table, ctx.model)
544  const cost = rates ? ` ≈ ${formatUsd((ctx.tokens * writeRate(rates, ttl)) / 1_000_000)}` : ''
545  $.ui.toast(
546    `Prompt cache has likely expired (idle ${Math.round(idleMs / 60_000)} min, ${ttl} cache): ` +
547      `this prompt re-writes ~${formatTokens(ctx.tokens)} tokens${cost}.`,
548    { timeoutMs: 10_000 },
549  )
550}
551
552export const register: Register = (on, options) => {
553  const settings = readSettings(options)
554  const { isSubscription } = settings
555
556  // Steps of parallel subagents finish concurrently; each read-modify-write of
557  // this session's store entry must see the previous one's result.
558  let queue: Promise<void> = Promise.resolve()
559  const serially = (task: () => Promise<void>) => {
560    const run = queue.then(task)
561    queue = run.catch(() => undefined)
562    return run
563  }
564
565  on('session.start', async ($, e, next) => {
566    await $.command.register({
567      name: 'costs',
568      description: 'Show session, weekly and monthly Claude Code costs; "export" writes a CSV, "doctor" checks the setup',
569      argumentHint: '[export|doctor]',
570    })
571    if (settings.table.error) $.ui.toast(`cost-ledger: ${settings.table.error}`)
572
573    const now = await $.clock.now()
574    await compactStore($, now)
575    await pruneBudgetKeys($, now)
576    $.clock.every(TICK_MS, () => void onTick($, settings))
577
578    const usage = await $.session.usage()
579    const usd = usage.cost?.usd ?? 0
580    // A resumed session can start with spend that an earlier run already
581    // recorded; only growth from here on is new. A reload keeps the baseline.
582    if ((await read($, lastUsd)) === null) await update($, lastUsd, () => usd)
583    await refresh($, settings, usd, usage.rateLimits)
584
585    return next(e)
586  })
587
588  on('classic.SessionStart', async ($, e, next) => {
589    // session.start fires once per process, but /clear, /resume and a fork
590    // switch sessions inside it: the new session's running total starts from
591    // whatever it carries, and none of that is new spend.
592    if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') await serially(() => rebaseline($))
593    if (e.source === 'clear' || e.source === 'compact') {
594      await update($, context, () => null)
595      await update($, loops, () => ({}))
596    }
597    if (e.source === 'clear') {
598      await update($, activeTurn, () => null)
599      await update($, turnPrompts, () => ({}))
600    }
601
602    // A resumed transcript's cache is as old as its last response, so the
603    // idle warning on the first prompt covers resumes too.
604    if (e.context_tokens && e.seconds_since_last_response !== undefined && e.model) {
605      const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
606      await update($, context, () => ({ tokens: e.context_tokens ?? 0, model: e.model ?? '', at }))
607    }
608    return next(e)
609  })
610
611  on('classic.PreModelSwitch', async ($, e, next) => {
612    await $.store.set(LEARNED_TTL_KEY, e.cache_ttl)
613    await $.store.set(LEARNED_PRICING_KEY, e.pricing)
614    if (e.prompt_cache_warm && e.context_tokens > 0) {
615      const rates = ratesFor(settings.table, e.to_model)
616      const usd = rates
617        ? (e.context_tokens * writeRate(rates, e.cache_ttl)) / 1_000_000
618        : e.estimated_cache_write_usd
619      $.ui.toast(
620        `Switching to ${e.to_model} gives up a warm prompt cache: ` +
621          `the next request re-writes ~${formatTokens(e.context_tokens)} tokens ≈ ${formatUsd(usd)}.`,
622        { timeoutMs: 10_000 },
623      )
624    }
625    return next(e)
626  })
627
628  on('classic.PostModelSwitch', async ($, e, next) => {
629    await $.store.set(LEARNED_TTL_KEY, e.cache_ttl)
630    await $.store.set(LEARNED_PRICING_KEY, e.pricing)
631    return next(e)
632  })
633
634  // Spend after the last measurement (a background subagent, an exit right
635  // after a turn) is otherwise never recorded; a /clear or /resume ends the
636  // old session here before its successor's baseline is taken.
637  on('session.end', async ($, e, next) => {
638    const usage = await $.session.usage()
639    const usd = usage.cost?.usd
640    if (usd !== undefined) await serially(() => recordSpend($, usd))
641    return next(e)
642  })
643
644  on('prompt.submit', async ($, e, next) => {
645    await warnIfCacheExpired($, settings)
646    return next(e)
647  })
648
649  on('turn.start', async ($, e, next) => {
650    const at = await $.clock.now()
651    await update($, turnPrompts, prompts => ({ ...prompts, [e.turnId]: { text: e.text, at } }))
652    return next(e)
653  })
654
655  on('turn.step', async function* ($, e, next) {
656    const step = { turnId: e.turnId, agentId: e.agentId, startedAt: await $.clock.now() }
657    const result = yield* next(e)
658    const usage = result.usage
659    if (usage) await serially(() => recordStep($, settings, usage, step))
660    return result
661  })
662
663  on('turn.complete', async ($, e, next) => {
664    const result = await next(e)
665    const agentId = e.agentId
666    if (agentId === undefined) await serially(() => finishActiveTurn($, settings, e.turnId))
667    else
668      await update($, loops, all => {
669        const { [agentId]: _done, ...rest } = all
670        return rest
671      })
672    return result
673  })
674
675  on('tool.call', async ($, e, next) => {
676    const result = await next(e)
677    const text = result.text
678    if (typeof text === 'string' && text.length > 0) {
679      await serially(() => recordToolResult($, String(e.tool), e.agentId, text))
680    }
681    return result
682  })
683
684  on('session.measure', async ($, e, next) => {
685    const cost = e.cost
686    if (cost) await serially(() => recordSpend($, cost.usd))
687    await refresh($, settings, e.cost?.usd ?? 0, e.rateLimits)
688
689    return next(e)
690  })
691
692  on('command.run', { command: 'costs' }, async ($, e) => {
693    const args = e.args.trim()
694    if (args === 'export') return { text: await writeExport($) }
695    if (args === 'doctor') return { text: await runDoctor($, settings) }
696
697    const usage = await $.session.usage()
698    await refresh($, settings, usage.cost?.usd ?? 0, usage.rateLimits)
699    await update($, monthOffset, () => 0)
700    await $.ui.open({ id: PANE, title: 'Costs' })
701
702    return { text: 'Costs pane opened.' }
703  })
704
705  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
706    const t = await read($, totals)
707    if (!options.band || e.props.hasSurvey || t === null) return next(e)
708
709    await read($, tick)
710    const { Text } = $.ui.resolve(e)
711    const contextPart = await contextLine($, settings, await read($, context))
712    return <Text dimColor>{summaryLine(t, settings, contextPart)}</Text>
713  })
714
715  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
716    const { Box, Button, Text } = $.ui.resolve(e)
717    const t = await read($, totals)
718    if (t === null) return <Text dimColor>No cost recorded yet.</Text>
719
720    const now = await $.clock.now()
721    const today = dayKey(now)
722    const offset = await read($, monthOffset)
723    const shading = await read($, shadeBy)
724    const ctx = await read($, context)
725    await read($, tick)
726    const shown = monthView(now, offset)
727    const days = daysIn(t.days, shown.key)
728    const shownTotal = days.reduce((sum, [, usd]) => sum + usd, 0)
729    const cache = sumMonth<Breakdown>(t.cacheDays, shown.key, addBreakdown)
730    const cacheSum = cache ? totalUsd(cache) : 0
731    const subagents = sumMonth<Breakdown>(t.subagentDays, shown.key, addBreakdown)
732    const subagentSum = subagents ? totalUsd(subagents) : 0
733    const projects = Object.entries(t.projectDays)
734      .map(([root, projectDays]) => {
735        const month = Object.entries(projectDays).filter(([day]) => day.startsWith(shown.key))
736        return {
737          name: projectName(root),
738          usd: month.reduce((sum, [, d]) => sum + d.usd, 0),
739          cacheWriteUsd: month.reduce((sum, [, d]) => sum + d.cacheWriteUsd, 0),
740        }
741      })
742      .filter(p => p.usd > 0 || p.cacheWriteUsd > 0)
743      .sort((a, b) => b.usd - a.usd)
744    const ttl = await cacheTtl($, settings)
745    const pace = monthPace(t.month, now)
746    const reachDay = budgetReachDay(t.month, settings.budgets.month, now)
747    const wow = weekOverWeek(t.days, now)
748    const monthFrom = `${shown.key}-01`
749    const monthTo = `${shown.key}-31`
750    const expensive = turnsIn(t.turns, monthFrom, monthTo).slice(0, SHOWN_TURNS)
751    const tools = topTools(t.toolDays, monthFrom, monthTo).slice(0, SHOWN_TOOLS)
752
753    const shadeValue = (day: string) =>
754      shading === 'cacheWrite' ? (t.cacheDays[day]?.usd.cacheWrite ?? 0) : (t.days[day] ?? 0)
755    const monthKeys = Object.keys({ ...t.days, ...t.cacheDays }).filter(day => day.startsWith(shown.key))
756    const shadePeak = Math.max(0, ...monthKeys.map(shadeValue))
757    const barPeak = Math.max(0, ...days.map(([, usd]) => usd))
758    const barRoom = Math.max(4, (e.props.bodyColumns ?? 40) - 24)
759
760    const row = (label: string, usd: number, note = '', budget = 0) => (
761      <Text>
762        {label.padEnd(12)}
763        {formatUsd(usd).padStart(10)}
764        {budget > 0 && (
765          <Text color={usd >= budget ? 'error' : usd >= budget * 0.8 ? 'warning' : undefined}>
766            {` of ${formatUsd(budget)} (${Math.round((usd / budget) * 100)}%)`}
767          </Text>
768        )}
769        <Text dimColor>{note}</Text>
770      </Text>
771    )
772    const cell = (day: number | null) => {
773      if (day === null) return <Text>{'   '}</Text>
774      const key = `${shown.key}-${String(day).padStart(2, '0')}`
775      const level = heatLevel(shadeValue(key), shadePeak)
776      return (
777        <Text>
778          {' '}
779          <Text
780            backgroundColor={level > 0 ? HEAT[level] : undefined}
781            color={level > 0 ? '#ffffff' : undefined}
782            dimColor={level === 0}
783            bold={key === today}
784            underline={key === today}
785          >
786            {String(day).padStart(2)}
787          </Text>
788        </Text>
789      )
790    }
791
792    return (
793      <Box flexDirection="column">
794        {isSubscription && <Text dimColor>Figures are API-equivalent, not billed amounts.</Text>}
795        {row('Session', t.session)}
796        {row('Today', t.today, '', settings.budgets.day)}
797        {row(
798          'This week',
799          t.week,
800          wow.change === null
801            ? `  since ${t.weekStart}`
802            : `  ${wow.change >= 0 ? '+' : ''}${Math.round(wow.change * 100)}% vs this point last week`,
803          settings.budgets.week,
804        )}
805        {row(
806          'This month',
807          t.month,
808          `  on pace for ${formatUsd(pace)}${reachDay ? `, budget reached ~${reachDay}` : ''}`,
809          settings.budgets.month,
810        )}
811        {isSubscription && t.rateLimits.length > 0 && <Text>Rate limits: {rateLimitText(t.rateLimits)}</Text>}
812        <Text> </Text>
813        <Box flexDirection="row">
814          {offset > OLDEST_MONTH_OFFSET ? (
815            <Button key="prev" label="◀" plain hotkey="p" onPress={() => update($, monthOffset, n => n - 1)} />
816          ) : (
817            <Text> </Text>
818          )}
819          <Text bold> {shown.name.padEnd(15)}</Text>
820          {offset < 0 ? (
821            <Button key="next" label="▶" plain hotkey="n" onPress={() => update($, monthOffset, n => n + 1)} />
822          ) : (
823            <Text> </Text>
824          )}
825        </Box>
826        <Text dimColor>{WEEKDAYS.map(d => ` ${d}`).join('')}</Text>
827        {shown.weeks.map(week => (
828          <Text>{week.map(cell)}</Text>
829        ))}
830        <Text>
831          <Text dimColor>{'less '}</Text>
832          {HEAT.slice(1).map(color => (
833            <Text backgroundColor={color}>{'  '}</Text>
834          ))}
835          <Text dimColor>{' more'}</Text>
836          {'   '}
837          {formatUsd(shownTotal)}
838          <Text dimColor> total</Text>
839        </Text>
840        <Box flexDirection="row">
841          <Text dimColor>Shade by </Text>
842          <Button
843            key="shade"
844            label={shading === 'total' ? 'total spend' : 'cache writes'}
845            plain
846            hotkey="s"
847            onPress={() => update($, shadeBy, s => (s === 'total' ? 'cacheWrite' : 'total'))}
848          />
849        </Box>
850        <Text> </Text>
851        <Text bold>
852          Prompt cache, {shown.name}
853          <Text dimColor> (estimated at {settings.hasCustomRates ? 'your rates' : 'list prices'})</Text>
854        </Text>
855        {cache === undefined ? (
856          <Text dimColor>No requests recorded.</Text>
857        ) : (
858          <Box flexDirection="column">
859            <Text dimColor>{'            '}{'tokens'.padStart(8)}{'cost'.padStart(11)}{'share'.padStart(7)}</Text>
860            {CATEGORIES.map(c => (
861              <Text>
862                {CATEGORY_NAMES[c].padEnd(12)}
863                {formatTokens(cache.tokens[c]).padStart(8)}
864                {formatUsd(cache.usd[c]).padStart(11)}
865                <Text dimColor>{`${cacheSum > 0 ? Math.round((cache.usd[c] / cacheSum) * 100) : 0}%`.padStart(7)}</Text>
866              </Text>
867            ))}
868            {(cache.unpricedTokens ?? 0) > 0 && (
869              <Text color="warning">
870                {formatTokens(cache.unpricedTokens ?? 0)} tokens from models with no rate are not priced; add them to
871                the rates setting
872              </Text>
873            )}
874            <Text dimColor>
875              Cache hit {Math.round(hitRate(cache) * 100)}% of prompt tokens
876              {settings.hasCustomRates ? '; totals above use Claude Code’s own pricing' : ''}
877            </Text>
878            <Text>
879              {'Subagents'.padEnd(12)}
880              {formatUsd(subagentSum).padStart(19)}
881              <Text dimColor>
882                {`${cacheSum > 0 ? Math.round((subagentSum / cacheSum) * 100) : 0}%`.padStart(7)}
883                {subagents ? `  incl. ${formatUsd(subagents.usd.cacheWrite)} cache writes` : ''}
884              </Text>
885            </Text>
886          </Box>
887        )}
888        {ctx !== null && ctx.tokens > 0 && (
889          <Text dimColor>
890            Current context {formatTokens(ctx.tokens)} on {ctx.model}
891            {perStepText(ctx, settings)}
892            {cacheText(ctx, ttl, now)}
893            {nudgeText(ctx, settings)}
894          </Text>
895        )}
896        <Text> </Text>
897        <Text bold>
898          Most expensive prompts, {shown.name}
899          <Text dimColor> (estimated, subagents included)</Text>
900        </Text>
901        {expensive.length === 0 && <Text dimColor>None recorded.</Text>}
902        {expensive.map(turn => (
903          <Text>
904            {formatUsd(turnUsd(turn)).padStart(9)}{' '}
905            <Text dimColor>{`${turn.steps}${turn.subagentSteps > 0 ? `+${turn.subagentSteps}` : ''} req `.padStart(10)}</Text>
906            {turn.prompt.slice(0, Math.max(10, (e.props.bodyColumns ?? 60) - 22))}
907          </Text>
908        ))}
909        <Text> </Text>
910        <Text bold>
911          What filled the context, {shown.name}
912          <Text dimColor> (tool results, ≈ tokens)</Text>
913        </Text>
914        {tools.length === 0 && <Text dimColor>No tool results recorded.</Text>}
915        {tools.length > 0 && (
916          <Text dimColor>{'tool'.padEnd(22)}{'calls'.padStart(6)}{'tokens'.padStart(8)}{'write'.padStart(9)}{'re-read'.padStart(10)}</Text>
917        )}
918        {tools.map(([tool, use]) => (
919          <Text>
920            {toolLabel(tool).slice(0, 21).padEnd(22)}
921            {String(use.calls).padStart(6)}
922            {formatTokens(use.tokens).padStart(8)}
923            {formatUsd(use.writeUsd).padStart(9)}
924            {formatUsd(use.rereadUsd).padStart(10)}
925          </Text>
926        ))}
927        <Text> </Text>
928        <Text bold>By project, {shown.name}</Text>
929        {projects.length === 0 && <Text dimColor>No spend recorded.</Text>}
930        {projects.slice(0, SHOWN_PROJECTS).map(p => (
931          <Text>
932            {p.name.slice(0, 20).padEnd(21)}
933            {formatUsd(p.usd).padStart(10)}
934            <Text dimColor>{`  ${formatUsd(p.cacheWriteUsd)} cache writes`}</Text>
935          </Text>
936        ))}
937        {projects.length > SHOWN_PROJECTS && (
938          <Text dimColor>and {projects.length - SHOWN_PROJECTS} more; /costs export has them all</Text>
939        )}
940        <Text> </Text>
941        <Text bold>Daily, {shown.name}</Text>
942        {days.length === 0 && <Text dimColor>No spend recorded.</Text>}
943        {days.map(([day, usd]) => (
944          <Text>
945            {day} {formatUsd(usd).padStart(9)}{' '}
946            <Text color="cyan">{'█'.repeat(Math.max(1, Math.round((usd / barPeak) * barRoom)))}</Text>
947          </Text>
948        ))}
949      </Box>
950    )
951  })
952}
953
hooks/insights.ts 195 lines
1import { addBreakdown, emptyBreakdown, totalUsd } from './pricing'
2import type { ActiveTurn, Breakdown, LoopContext, ToolUse, TurnRecord } from '../types'
3
4const DAY_MS = 24 * 60 * 60 * 1000
5const PROMPT_CHARS = 80
6export const TURNS_PER_MONTH = 10
7
8const pad = (n: number) => String(n).padStart(2, '0')
9const localDay = (ms: number) => {
10  const d = new Date(ms)
11  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
12}
13
14// ---- Cost per prompt -------------------------------------------------------
15
16export function startTurn(turnId: string, text: string, now: number): ActiveTurn {
17  const prompt = text.replace(/\s+/g, ' ').trim()
18  return {
19    turnId,
20    prompt: prompt.length > PROMPT_CHARS ? `${prompt.slice(0, PROMPT_CHARS - 1)}…` : prompt,
21    at: now,
22    steps: 0,
23    subagentSteps: 0,
24    usage: emptyBreakdown(),
25    subagentUsage: emptyBreakdown(),
26  }
27}
28
29export function addStepToTurn(turn: ActiveTurn, usage: Breakdown, isSubagent: boolean): ActiveTurn {
30  return {
31    ...turn,
32    steps: turn.steps + (isSubagent ? 0 : 1),
33    subagentSteps: turn.subagentSteps + (isSubagent ? 1 : 0),
34    usage: addBreakdown(turn.usage, usage),
35    subagentUsage: isSubagent ? addBreakdown(turn.subagentUsage, usage) : turn.subagentUsage,
36  }
37}
38
39export function finishTurn(turn: ActiveTurn, project: string): TurnRecord {
40  const { turnId: _turnId, ...record } = turn
41  return { ...record, project }
42}
43
44export function turnUsd(turn: TurnRecord): number {
45  return totalUsd(turn.usage)
46}
47
48// Keeps the most expensive prompts of each month, so a month's top list
49// survives however many cheap prompts follow it.
50export function keepTopTurns(turns: readonly TurnRecord[]): TurnRecord[] {
51  const byMonth = new Map<string, TurnRecord[]>()
52  for (const turn of turns) {
53    const month = localDay(turn.at).slice(0, 7)
54    byMonth.set(month, [...(byMonth.get(month) ?? []), turn])
55  }
56  return [...byMonth.values()].flatMap(month =>
57    [...month].sort((a, b) => turnUsd(b) - turnUsd(a)).slice(0, TURNS_PER_MONTH),
58  )
59}
60
61export function turnsIn(turns: readonly TurnRecord[], fromDay: string, toDay: string): TurnRecord[] {
62  return turns
63    .filter(turn => {
64      const day = localDay(turn.at)
65      return day >= fromDay && day <= toDay
66    })
67    .sort((a, b) => turnUsd(b) - turnUsd(a))
68}
69
70// ---- What fills the context --------------------------------------------------
71
72// Tool results are measured in characters; four per token is the usual rough
73// ratio for English and code, so these figures are approximate by design.
74export function estimateTokens(text: string): number {
75  return Math.ceil(text.length / 4)
76}
77
78export function toolLabel(tool: string): string {
79  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
80  return mcp ? `${mcp[1]}/${mcp[2]}` : tool
81}
82
83export function emptyLoop(): LoopContext {
84  return { pending: {}, resident: {} }
85}
86
87export function addToolResult(loop: LoopContext | undefined, tool: string, tokens: number): LoopContext {
88  const current = loop ?? emptyLoop()
89  return { ...current, pending: { ...current.pending, [tool]: (current.pending[tool] ?? 0) + tokens } }
90}
91
92export type ToolCharges = Record<string, { writeUsd: number; rereadUsd: number }>
93
94// One model request of a loop: results already cached are read again at the
95// read rate, and results added since the last request are written at the write
96// rate, after which they are cached too.
97export function chargeStep(
98  loop: LoopContext,
99  readPerToken: number,
100  writePerToken: number,
101): { loop: LoopContext; charges: ToolCharges } {
102  const charges: ToolCharges = {}
103  const charge = (tool: string) => (charges[tool] ??= { writeUsd: 0, rereadUsd: 0 })
104  for (const [tool, tokens] of Object.entries(loop.resident)) charge(tool).rereadUsd += tokens * readPerToken
105  const resident = { ...loop.resident }
106  for (const [tool, tokens] of Object.entries(loop.pending)) {
107    charge(tool).writeUsd += tokens * writePerToken
108    resident[tool] = (resident[tool] ?? 0) + tokens
109  }
110  return { loop: { pending: {}, resident }, charges }
111}
112
113const emptyToolUse = (): ToolUse => ({ calls: 0, tokens: 0, writeUsd: 0, rereadUsd: 0 })
114
115export function addToolUse(a: ToolUse | undefined, b: ToolUse): ToolUse {
116  const sum = a ?? emptyToolUse()
117  return {
118    calls: sum.calls + b.calls,
119    tokens: sum.tokens + b.tokens,
120    writeUsd: sum.writeUsd + b.writeUsd,
121    rereadUsd: sum.rereadUsd + b.rereadUsd,
122  }
123}
124
125export function mergeToolMaps(
126  a: Record<string, ToolUse> | undefined,
127  b: Record<string, ToolUse> | undefined,
128): Record<string, ToolUse> {
129  const merged = { ...a }
130  for (const [tool, use] of Object.entries(b ?? {})) merged[tool] = addToolUse(merged[tool], use)
131  return merged
132}
133
134export function chargesAsUse(charges: ToolCharges): Record<string, ToolUse> {
135  return Object.fromEntries(
136    Object.entries(charges).map(([tool, c]) => [tool, { ...emptyToolUse(), writeUsd: c.writeUsd, rereadUsd: c.rereadUsd }]),
137  )
138}
139
140export function topTools(
141  toolDays: Record<string, Record<string, ToolUse>>,
142  fromDay: string,
143  toDay: string,
144): [string, ToolUse][] {
145  let merged: Record<string, ToolUse> = {}
146  for (const [day, tools] of Object.entries(toolDays)) {
147    if (day >= fromDay && day <= toDay) merged = mergeToolMaps(merged, tools)
148  }
149  return Object.entries(merged).sort(([, a], [, b]) => b.writeUsd + b.rereadUsd - (a.writeUsd + a.rereadUsd))
150}
151
152// ---- Pace and week over week ------------------------------------------------
153
154export function monthBounds(now: number): { start: number; end: number; days: number } {
155  const d = new Date(now)
156  const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
157  const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
158  return { start, end, days: Math.round((end - start) / DAY_MS) }
159}
160
161// Month-to-date spend extrapolated at its average daily rate; at least one
162// day has elapsed so a busy first morning does not project a fortune.
163export function monthPace(monthUsd: number, now: number): number {
164  const { start, days } = monthBounds(now)
165  const elapsed = Math.max(1, (now - start) / DAY_MS)
166  return (monthUsd / elapsed) * days
167}
168
169// The local day on which spend at the month's average daily rate reaches the
170// budget, or null when it is already reached or will not be this month.
171export function budgetReachDay(monthUsd: number, budget: number, now: number): string | null {
172  if (budget <= 0 || monthUsd >= budget || monthUsd <= 0) return null
173  const { start, end } = monthBounds(now)
174  const rate = monthUsd / Math.max(1, (now - start) / DAY_MS)
175  const reachAt = now + ((budget - monthUsd) / rate) * DAY_MS
176  return reachAt < end ? localDay(reachAt) : null
177}
178
179// This week so far against last week up to the same weekday, by local day.
180export function weekOverWeek(
181  days: Record<string, number>,
182  now: number,
183): { thisWeek: number; lastWeek: number; change: number | null } {
184  const d = new Date(now)
185  const sinceMonday = (d.getDay() + 6) % 7
186  const dayAt = (offset: number) => localDay(new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset).getTime())
187  let thisWeek = 0
188  let lastWeek = 0
189  for (let i = -sinceMonday; i <= 0; i++) {
190    thisWeek += days[dayAt(i)] ?? 0
191    lastWeek += days[dayAt(i - 7)] ?? 0
192  }
193  return { thisWeek, lastWeek, change: lastWeek > 0 ? (thisWeek - lastWeek) / lastWeek : null }
194}
195
hooks/ledger.ts 334 lines
1import { keepTopTurns, mergeToolMaps } from './insights'
2import { addBreakdown, CATEGORIES, emptyBreakdown } from './pricing'
3import type { Breakdown, ProjectDay, ToolUse, Totals, TurnRecord } from '../types'
4
5export type SessionEntry = {
6  project?: string
7  days: Record<string, number>
8  cache?: Record<string, Breakdown>
9  subagentCache?: Record<string, Breakdown>
10  turns?: TurnRecord[]
11  tools?: Record<string, Record<string, ToolUse>>
12  updatedAt: number
13}
14
15export type Summary = {
16  today: number
17  week: number
18  month: number
19  weekStart: string
20  days: Record<string, number>
21  cacheDays: Record<string, Breakdown>
22  subagentDays: Record<string, Breakdown>
23  projectDays: Record<string, Record<string, ProjectDay>>
24  turns: TurnRecord[]
25  toolDays: Record<string, Record<string, ToolUse>>
26}
27
28export type MonthView = { key: string; name: string; weeks: (number | null)[][] }
29
30const DAY_MS = 24 * 60 * 60 * 1000
31const RETENTION_MS = 400 * DAY_MS
32const ARCHIVE_AFTER_MS = 60 * DAY_MS
33export const OLDEST_MONTH_OFFSET = -13
34export const UNKNOWN_PROJECT = '(unknown)'
35export const BUDGET_THRESHOLDS = [0.8, 1] as const
36
37const MONTH_NAMES = [
38  'January', 'February', 'March', 'April', 'May', 'June',
39  'July', 'August', 'September', 'October', 'November', 'December',
40]
41
42const pad = (n: number) => String(n).padStart(2, '0')
43
44export function dayKey(ms: number): string {
45  const d = new Date(ms)
46  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
47}
48
49export function weekStartKey(ms: number): string {
50  const d = new Date(ms)
51  const sinceMonday = (d.getDay() + 6) % 7
52  return dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - sinceMonday).getTime())
53}
54
55// The engine's figure is a running total that a /clear may reset to zero, so a
56// drop means a fresh total rather than a refund.
57export function spendSince(previous: number | null, usd: number): number {
58  if (previous === null || usd < previous) return usd
59  return usd - previous
60}
61
62export function addSpend(
63  entry: SessionEntry | undefined,
64  now: number,
65  usd: number,
66  project: string,
67): SessionEntry {
68  const day = dayKey(now)
69  const days = { ...entry?.days }
70  days[day] = (days[day] ?? 0) + usd
71  return { ...entry, project, days, updatedAt: now }
72}
73
74export function addUsage(
75  entry: SessionEntry | undefined,
76  now: number,
77  usage: Breakdown,
78  project: string,
79  isSubagent: boolean,
80): SessionEntry {
81  const day = dayKey(now)
82  const cache = { ...entry?.cache }
83  cache[day] = addBreakdown(cache[day], usage)
84  const subagentCache = { ...entry?.subagentCache }
85  if (isSubagent) subagentCache[day] = addBreakdown(subagentCache[day], usage)
86  return { days: {}, ...entry, project, cache, subagentCache, updatedAt: now }
87}
88
89export function addTurn(entry: SessionEntry | undefined, now: number, turn: TurnRecord): SessionEntry {
90  return { days: {}, ...entry, turns: keepTopTurns([...(entry?.turns ?? []), turn]), updatedAt: now }
91}
92
93export function addTools(entry: SessionEntry | undefined, now: number, tools: Record<string, ToolUse>): SessionEntry {
94  const day = dayKey(now)
95  const all = { ...entry?.tools }
96  all[day] = mergeToolMaps(all[day], tools)
97  return { days: {}, ...entry, tools: all, updatedAt: now }
98}
99
100export function shouldArchive(entry: SessionEntry, now: number): boolean {
101  return now - entry.updatedAt > ARCHIVE_AFTER_MS
102}
103
104function mergeDays<T>(a: Record<string, T> | undefined, b: Record<string, T> | undefined, add: (x: T, y: T) => T) {
105  const merged = { ...a }
106  for (const [day, value] of Object.entries(b ?? {})) {
107    const existing = merged[day]
108    merged[day] = existing === undefined ? value : add(existing, value)
109  }
110  return merged
111}
112
113// Folds a finished session into its project's archive entry, so the store
114// holds one entry per project for old history instead of one per session.
115export function mergeEntries(archive: SessionEntry | undefined, entry: SessionEntry): SessionEntry {
116  return {
117    project: entry.project ?? archive?.project,
118    days: mergeDays(archive?.days, entry.days, (x, y) => x + y),
119    cache: mergeDays(archive?.cache, entry.cache, addBreakdown),
120    subagentCache: mergeDays(archive?.subagentCache, entry.subagentCache, addBreakdown),
121    turns: keepTopTurns([...(archive?.turns ?? []), ...(entry.turns ?? [])]),
122    tools: mergeDays(archive?.tools, entry.tools, mergeToolMaps),
123    updatedAt: Math.max(archive?.updatedAt ?? 0, entry.updatedAt),
124  }
125}
126
127// Drops the days past retention; undefined once nothing is left.
128export function pruneDays(entry: SessionEntry, now: number): SessionEntry | undefined {
129  const cutoff = dayKey(now - RETENTION_MS)
130  const keep = <T>(days: Record<string, T> | undefined) =>
131    Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= cutoff))
132  const pruned = {
133    ...entry,
134    days: keep(entry.days),
135    cache: keep(entry.cache),
136    subagentCache: keep(entry.subagentCache),
137    tools: keep(entry.tools),
138    turns: (entry.turns ?? []).filter(turn => dayKey(turn.at) >= cutoff),
139  }
140  const isEmpty =
141    [pruned.days, pruned.cache, pruned.subagentCache, pruned.tools].every(d => Object.keys(d).length === 0) &&
142    pruned.turns.length === 0
143  return isEmpty ? undefined : pruned
144}
145
146// Adds one request's usage to the drawn totals in place of a full store scan,
147// which would run on every model response.
148export function applyUsage(t: Totals, now: number, usage: Breakdown, project: string, isSubagent: boolean): Totals {
149  const day = dayKey(now)
150  const projectDays = { ...t.projectDays[project] }
151  const projectDay = projectDays[day] ?? { usd: 0, cacheWriteUsd: 0 }
152  projectDays[day] = { ...projectDay, cacheWriteUsd: projectDay.cacheWriteUsd + usage.usd.cacheWrite }
153  return {
154    ...t,
155    cacheDays: { ...t.cacheDays, [day]: addBreakdown(t.cacheDays[day], usage) },
156    subagentDays: isSubagent ? { ...t.subagentDays, [day]: addBreakdown(t.subagentDays[day], usage) } : t.subagentDays,
157    projectDays: { ...t.projectDays, [project]: projectDays },
158  }
159}
160
161export function applyTurn(t: Totals, turn: TurnRecord): Totals {
162  return { ...t, turns: keepTopTurns([...t.turns, turn]) }
163}
164
165export function applyTools(t: Totals, now: number, tools: Record<string, ToolUse>): Totals {
166  const day = dayKey(now)
167  return { ...t, toolDays: { ...t.toolDays, [day]: mergeToolMaps(t.toolDays[day], tools) } }
168}
169
170export function summarize(entries: readonly SessionEntry[], now: number): Summary {
171  const today = dayKey(now)
172  const weekStart = weekStartKey(now)
173  const month = today.slice(0, 7)
174  const summary: Summary = {
175    today: 0,
176    week: 0,
177    month: 0,
178    weekStart,
179    days: {},
180    cacheDays: {},
181    subagentDays: {},
182    projectDays: {},
183    turns: [],
184    toolDays: {},
185  }
186  const turns: TurnRecord[] = []
187
188  for (const entry of entries) {
189    const projectDays = (summary.projectDays[entry.project ?? UNKNOWN_PROJECT] ??= {})
190    const projectDay = (day: string) => (projectDays[day] ??= { usd: 0, cacheWriteUsd: 0 })
191    for (const [day, usd] of Object.entries(entry.days)) {
192      if (day > today) continue
193      if (day === today) summary.today += usd
194      if (day >= weekStart) summary.week += usd
195      if (day.startsWith(month)) summary.month += usd
196      summary.days[day] = (summary.days[day] ?? 0) + usd
197      projectDay(day).usd += usd
198    }
199    for (const [day, usage] of Object.entries(entry.cache ?? {})) {
200      summary.cacheDays[day] = addBreakdown(summary.cacheDays[day], usage)
201      projectDay(day).cacheWriteUsd += usage.usd.cacheWrite
202    }
203    for (const [day, usage] of Object.entries(entry.subagentCache ?? {})) {
204      summary.subagentDays[day] = addBreakdown(summary.subagentDays[day], usage)
205    }
206    for (const [day, tools] of Object.entries(entry.tools ?? {})) {
207      summary.toolDays[day] = mergeToolMaps(summary.toolDays[day], tools)
208    }
209    turns.push(...(entry.turns ?? []))
210  }
211  summary.turns = keepTopTurns(turns)
212  return summary
213}
214
215export function formatUsd(usd: number): string {
216  return `$${usd.toFixed(2)}`
217}
218
219export function monthView(now: number, offset: number): MonthView {
220  const today = new Date(now)
221  const first = new Date(today.getFullYear(), today.getMonth() + offset, 1)
222  const year = first.getFullYear()
223  const month = first.getMonth()
224  const length = new Date(year, month + 1, 0).getDate()
225  const cells: (number | null)[] = Array((first.getDay() + 6) % 7).fill(null)
226  for (let day = 1; day <= length; day++) cells.push(day)
227  while (cells.length % 7 !== 0) cells.push(null)
228
229  const weeks: (number | null)[][] = []
230  for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
231
232  const name = `${MONTH_NAMES[month]} ${year}`
233  return { key: `${year}-${pad(month + 1)}`, name, weeks }
234}
235
236export function daysIn(days: Record<string, number>, monthKey: string): [string, number][] {
237  return Object.entries(days)
238    .filter(([day]) => day.startsWith(monthKey))
239    .sort(([a], [b]) => a.localeCompare(b))
240}
241
242export function heatLevel(usd: number, peak: number): 0 | 1 | 2 | 3 | 4 {
243  if (usd <= 0 || peak <= 0) return 0
244  return Math.min(4, Math.max(1, Math.ceil((usd / peak) * 4))) as 1 | 2 | 3 | 4
245}
246
247export function formatTokens(tokens: number): string {
248  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
249  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
250  return String(tokens)
251}
252
253export function projectName(root: string): string {
254  return root.split(/[\\/]/).filter(Boolean).at(-1) ?? root
255}
256
257export function sumMonth<T>(days: Record<string, T>, monthKey: string, add: (sum: T | undefined, day: T) => T) {
258  return Object.entries(days)
259    .filter(([day]) => day.startsWith(monthKey))
260    .reduce<T | undefined>((sum, [, value]) => add(sum, value), undefined)
261}
262
263// The highest threshold the spend has reached that has not been announced yet,
264// so 80% and 100% are each said once per period.
265export function budgetAlert(spent: number, limit: number, announced: number): number | null {
266  if (limit <= 0) return null
267  const reached = BUDGET_THRESHOLDS.filter(t => spent >= limit * t).at(-1)
268  return reached !== undefined && reached > announced ? reached : null
269}
270
271export function cacheMinutesLeft(lastRequestAt: number, ttlMs: number, now: number): number {
272  return Math.max(0, Math.ceil((lastRequestAt + ttlMs - now) / 60_000))
273}
274
275const CSV_HEADER = [
276  'date',
277  'project',
278  'total_usd',
279  ...CATEGORIES.map(c => `${c}_tokens`),
280  ...CATEGORIES.map(c => `${c}_usd_estimated`),
281  'subagent_usd_estimated',
282]
283
284const csvField = (value: string | number) => {
285  const text = String(value)
286  return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
287}
288
289type CsvRow = { usd: number; usage: Breakdown; subagent: Breakdown }
290
291export function exportCsv(entries: readonly SessionEntry[]): string {
292  const byDay = new Map<string, Map<string, CsvRow>>()
293  const row = (day: string, project: string) => {
294    const projects = byDay.get(day) ?? new Map<string, CsvRow>()
295    byDay.set(day, projects)
296    const found = projects.get(project) ?? { usd: 0, usage: emptyBreakdown(), subagent: emptyBreakdown() }
297    projects.set(project, found)
298    return found
299  }
300
301  for (const entry of entries) {
302    const project = entry.project ?? UNKNOWN_PROJECT
303    for (const [day, usd] of Object.entries(entry.days)) row(day, project).usd += usd
304    for (const [day, usage] of Object.entries(entry.cache ?? {})) {
305      const r = row(day, project)
306      r.usage = addBreakdown(r.usage, usage)
307    }
308    for (const [day, usage] of Object.entries(entry.subagentCache ?? {})) {
309      const r = row(day, project)
310      r.subagent = addBreakdown(r.subagent, usage)
311    }
312  }
313
314  const lines: string[] = [CSV_HEADER.join(',')]
315  for (const day of [...byDay.keys()].sort()) {
316    const projects = byDay.get(day) ?? new Map<string, CsvRow>()
317    for (const project of [...projects.keys()].sort()) {
318      const r = projects.get(project)
319      if (!r) continue
320      const subagentUsd = CATEGORIES.reduce((sum, c) => sum + r.subagent.usd[c], 0)
321      const fields = [
322        day,
323        project,
324        r.usd.toFixed(4),
325        ...CATEGORIES.map(c => r.usage.tokens[c]),
326        ...CATEGORIES.map(c => r.usage.usd[c].toFixed(4)),
327        subagentUsd.toFixed(4),
328      ]
329      lines.push(fields.map(csvField).join(','))
330    }
331  }
332  return lines.join('\n') + '\n'
333}
334
hooks/pricing.ts 175 lines
1import type { Breakdown, Category } from '../types'
2
3export type Rates = {
4  input: number
5  output: number
6  cacheRead: number
7  cacheWrite5m: number
8  cacheWrite1h: number
9}
10
11export type CacheTtl = '5m' | '1h'
12
13export type Usage = {
14  input_tokens: number
15  output_tokens: number
16  cache_read_input_tokens: number
17  cache_creation_input_tokens: number
18}
19
20export const CATEGORIES: readonly Category[] = ['input', 'output', 'cacheRead', 'cacheWrite']
21
22const listRates = (input: number, output: number, cacheRead: number): Rates => ({
23  input,
24  output,
25  cacheRead,
26  cacheWrite5m: input * 1.25,
27  cacheWrite1h: input * 2,
28})
29
30// Anthropic list prices in USD per million tokens. Models without a price in
31// the bundled reference (Opus 4.5, Opus 4.1, Sonnet 4.5) are left out rather
32// than guessed: their tokens are counted as unpriced until the rates setting
33// names them.
34export const LIST_RATES: readonly [string, Rates][] = [
35  ['mythos-5-1', listRates(10, 50, 0.25)],
36  ['mythos-5', listRates(10, 50, 1)],
37  ['fable-5-1', listRates(10, 50, 0.25)],
38  ['fable-5', listRates(10, 50, 1)],
39  ['opus-5-5', listRates(4, 20, 0.2)],
40  ['opus-5', listRates(5, 25, 0.5)],
41  ['opus-4-8', listRates(5, 25, 0.5)],
42  ['opus-4-7', listRates(5, 25, 0.5)],
43  ['opus-4-6', listRates(5, 25, 0.5)],
44  ['sonnet-5-5', listRates(2, 10, 0.2)],
45  ['sonnet-5', listRates(2, 10, 0.2)],
46  ['sonnet-4-6', listRates(3, 15, 0.3)],
47  ['haiku-4-5', listRates(1, 5, 0.1)],
48]
49
50const RATE_FIELDS: readonly (keyof Rates)[] = ['input', 'output', 'cacheRead', 'cacheWrite5m', 'cacheWrite1h']
51
52export type RateTable = { rates: readonly [string, Rates][]; error?: string }
53
54const isRecord = (value: unknown): value is Record<string, unknown> =>
55  typeof value === 'object' && value !== null && !Array.isArray(value)
56
57const isRate = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
58
59// Overrides are per family and may name only some fields, so an AWS price list
60// can be pasted in for the models actually used. A family that has no list
61// price must give input, output and cacheRead; anything malformed is skipped
62// and named in the error rather than failing the mod.
63export function rateTable(overridesJson: string): RateTable {
64  if (overridesJson.trim() === '') return { rates: LIST_RATES }
65
66  let overrides: unknown
67  try {
68    overrides = JSON.parse(overridesJson)
69  } catch {
70    return { rates: LIST_RATES, error: 'the rates setting is not valid JSON; list prices are used' }
71  }
72  if (!isRecord(overrides)) {
73    return { rates: LIST_RATES, error: 'the rates setting must be a JSON object; list prices are used' }
74  }
75
76  const merged = new Map(LIST_RATES)
77  const skipped: string[] = []
78  for (const [family, value] of Object.entries(overrides)) {
79    if (!isRecord(value)) {
80      skipped.push(family)
81      continue
82    }
83    const partial: Partial<Rates> = {}
84    for (const field of RATE_FIELDS) {
85      if (isRate(value[field])) partial[field] = value[field]
86    }
87    const base = merged.get(family)
88    if (base) {
89      merged.set(family, { ...base, ...partial })
90    } else if (partial.input !== undefined && partial.output !== undefined && partial.cacheRead !== undefined) {
91      merged.set(family, { ...listRates(partial.input, partial.output, partial.cacheRead), ...partial })
92    } else {
93      skipped.push(family)
94    }
95  }
96  const error = skipped.length > 0 ? `rates for ${skipped.join(', ')} were skipped as incomplete or malformed` : undefined
97  return { rates: [...merged.entries()], error }
98}
99
100// A family matches where the id continues with something other than another
101// version number, so "claude-opus-5" does not claim "claude-opus-5-5" while a
102// date ("-20250929") or a Bedrock suffix ("-v1:0") still matches.
103function matchesFamily(model: string, family: string): boolean {
104  let at = model.indexOf(family)
105  while (at !== -1) {
106    const rest = model.slice(at + family.length)
107    if (!/^(\d|-\d{1,2}(?!\d))/.test(rest)) return true
108    at = model.indexOf(family, at + 1)
109  }
110  return false
111}
112
113export function ratesFor(table: RateTable, model: string): Rates | undefined {
114  let best: [string, Rates] | undefined
115  for (const entry of table.rates) {
116    if (matchesFamily(model, entry[0]) && (best === undefined || entry[0].length > best[0].length)) best = entry
117  }
118  return best?.[1]
119}
120
121export function writeRate(rates: Rates, ttl: CacheTtl): number {
122  return ttl === '1h' ? rates.cacheWrite1h : rates.cacheWrite5m
123}
124
125export function emptyBreakdown(): Breakdown {
126  return {
127    tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
128    usd: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
129    unpricedTokens: 0,
130  }
131}
132
133export function priceUsage(usage: Usage, rates: Rates | undefined, ttl: CacheTtl): Breakdown {
134  const tokens = {
135    input: usage.input_tokens,
136    output: usage.output_tokens,
137    cacheRead: usage.cache_read_input_tokens,
138    cacheWrite: usage.cache_creation_input_tokens,
139  }
140  if (rates === undefined) {
141    const unpricedTokens = CATEGORIES.reduce((sum, c) => sum + tokens[c], 0)
142    return { tokens, usd: emptyBreakdown().usd, unpricedTokens }
143  }
144  const perToken = (rate: number) => rate / 1_000_000
145  return {
146    tokens,
147    usd: {
148      input: tokens.input * perToken(rates.input),
149      output: tokens.output * perToken(rates.output),
150      cacheRead: tokens.cacheRead * perToken(rates.cacheRead),
151      cacheWrite: tokens.cacheWrite * perToken(writeRate(rates, ttl)),
152    },
153    unpricedTokens: 0,
154  }
155}
156
157export function addBreakdown(a: Breakdown | undefined, b: Breakdown): Breakdown {
158  const sum = emptyBreakdown()
159  for (const c of CATEGORIES) {
160    sum.tokens[c] = (a?.tokens[c] ?? 0) + b.tokens[c]
161    sum.usd[c] = (a?.usd[c] ?? 0) + b.usd[c]
162  }
163  sum.unpricedTokens = (a?.unpricedTokens ?? 0) + (b.unpricedTokens ?? 0)
164  return sum
165}
166
167export function hitRate(b: Breakdown): number {
168  const prompt = b.tokens.input + b.tokens.cacheRead + b.tokens.cacheWrite
169  return prompt === 0 ? 0 : b.tokens.cacheRead / prompt
170}
171
172export function totalUsd(b: Breakdown): number {
173  return CATEGORIES.reduce((sum, c) => sum + b.usd[c], 0)
174}
175
types/index.d.ts 69 lines
1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Category = 'input' | 'output' | 'cacheRead' | 'cacheWrite'
4
5export type Breakdown = {
6  tokens: Record<Category, number>
7  usd: Record<Category, number>
8  // Tokens from models with no rate; absent in entries written before 0.4.0.
9  unpricedTokens?: number
10}
11
12export type ProjectDay = { usd: number; cacheWriteUsd: number }
13
14// One prompt's cost, its subagents included, estimated from its requests.
15export type TurnRecord = {
16  at: number
17  prompt: string
18  project: string
19  steps: number
20  subagentSteps: number
21  usage: Breakdown
22  subagentUsage: Breakdown
23}
24
25export type ActiveTurn = Omit<TurnRecord, 'project'> & { turnId: string }
26
27// What one tool's results cost while they sat in context: approximate tokens,
28// the cache write when first sent, and every cached re-read after that.
29export type ToolUse = { calls: number; tokens: number; writeUsd: number; rereadUsd: number }
30
31// A loop's tool results by tool, in approximate tokens: pending until the next
32// request sends them, resident once cached.
33export type LoopContext = { pending: Record<string, number>; resident: Record<string, number> }
34
35export type Totals = {
36  session: number
37  today: number
38  week: number
39  month: number
40  weekStart: string
41  days: Record<string, number>
42  cacheDays: Record<string, Breakdown>
43  subagentDays: Record<string, Breakdown>
44  projectDays: Record<string, Record<string, ProjectDay>>
45  turns: TurnRecord[]
46  toolDays: Record<string, Record<string, ToolUse>>
47  rateLimits: RateLimit[]
48}
49
50export type Context = { tokens: number; model: string; at: number }
51
52export type Shading = 'total' | 'cacheWrite'
53
54declare module 'claude-code' {
55  interface PluginState {
56    'cost-ledger': {
57      totals: Totals | null
58      lastUsd: number | null
59      monthOffset: number
60      context: Context | null
61      shadeBy: Shading
62      tick: number
63      activeTurn: ActiveTurn | null
64      turnPrompts: Record<string, { text: string; at: number }>
65      loops: Record<string, LoopContext>
66    }
67  }
68}
69