SLOPSHOPPER

token-ledger

Real usage above the prompt, a cost line under each answer, and /tokens: where the tokens went by project, task, model and agent

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-08vladpolewoi/config76/claude/mods/token-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-ledger
│ ┃ Tokens ✕ › fix the failing auth test and add an audit log call │ ┃ [ today ] [ 7d ] [ 30d ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ $0.00 API-equivalent · 0 tokens · 0 sessions ⎿ Read 6 lines │ ┃ since 2025-10-09 ⏺ Update(src/auth.ts) │ ┃ 0 in (0% cached) · 0 out · $ estimated from ⎿ Added 2 lines, removed 1 line │ ┃ list prices ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ By project │ ┃ nothing yet ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ By task ✻ Worked for 42s · done 4:20 PM │ ┃ nothing yet ↳ 97k in (93% cached) · 1.5k out │ ┃ │ ┃ By model › /tokens │ ┃ nothing yet │ ┃ │ ┃ By agent │ ┃ nothing yet │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tokens
[ today ] [ 7d ] [ 30d ] $0.00 API-equivalent · 0 tokens · 0 sessions since 2025-10-09 0 in (0% cached) · 0 out · $ estimated from list prices By project nothing yet By task nothing yet By model nothing yet By agent nothing yet
README

config76

Dotfiles and machine setup for macOS and Arch Linux.

Structure

config76/
  .config/       Shared configs: nvim, tmux, ghostty
  .local/        Shared scripts
  claude/        Shared Claude Code config (mcp, settings, skills, mods)
  .zshrc         Base shell config (sourced by machine ~/.zshrc)
  mac/           macOS-specific setup + overlays
  arch/          Arch Linux-specific setup + overlays

Config is shared by default. Platform dirs (mac/, arch/) hold only OS-specific setup and thin overlays over the shared base.

Setup

macOS

cd mac
bash install.sh   # interactive — pick what to install
bash env.sh       # symlink dotfiles and configs

Arch

cd arch
bash env.sh       # copy dotfiles and configs

Two-way sync (config-sync)

env.sh symlinks the repo into place, so editing a tracked config edits the repo directly. The sync/ tools handle the rest: discovering new or drifted configs, classifying them, and pushing.

config-sync            # or: config-sync status  — show what drifted (read-only)
config-sync pull       # repo → machine: git pull --rebase + re-run env.sh
config-sync push "msg" # machine → repo: stage + secrets-gate + rebase + push

config-sync is installed onto PATH by env.sh (symlinked into ~/.local/scripts) and works identically on Arch and macOS.

Deciding where a new config belongs (shared vs arch vs mac vs a package to install) and resolving conflicts needs judgment — run the dotfiles-sync Claude Code skill. It reads config-sync status --json, classifies each item, writes a plan.json, and drives sync/apply.sh (dry-run first) then the push.

Under the hood (sync/):

ScriptRoleSide effects
status.sh [--json]drift discoverynone
apply.sh <plan> [--apply]execute a plan (adopt / resolve / install / ignore)dry-run by default, secrets-gated
pull.sh / push.shgit wrappersyes
ignore.txtnoise/secret patterns status.sh skips—

Every write path runs a secrets scan before anything enters git.


Required Secrets

Some configs depend on machine-specific values that are never committed. Each platform has a secrets.env.example — copy it to secrets.env and fill in real values. secrets.env is gitignored.

arch/secrets.env

VariableDescription
DSD_CALENDAR_HOSTCalendar server IP
DSD_DEV_HOSTDev server IP
CONSULT_ANTHROPIC_API_KEYAnthropic API key for the consult MCP server (claude/mcp.json)
TG_MCP_ALLOWLISTAllowlist for the telegram MCP server (claude/mcp.json)
cp arch/secrets.env.example arch/secrets.env
# edit with real values

MCP servers

MCP servers are shared in claude/mcp.json and applied on both machines. Each platform adds only OS-specific servers in its overlay (mac/.claude/mcp.json = XcodeBuildMCP; arch/.claude/mcp.json = empty). claude/merge-mcp.py (run by env.sh / runs/claude.sh) unions the shared base + overlay into ~/.claude.json — Claude Code reads servers from there, not from ~/.claude/mcp.json. The merge is additive: manually-added servers survive.

Secrets are never hardcoded — they reference environment variables (e.g. ${CONSULT_ANTHROPIC_API_KEY}, ${TG_MCP_ALLOWLIST}), which Claude Code expands from the shell that launched it. Local paths use ${HOME} so the committed config stays portable. Some servers also need a local checkout at ~/code/… — see claude/README.md for the full server/secret/prerequisite table.

So on a fresh machine the consult (or telegram) server will not start until its key is present in the shell environment. Put the value in arch/secrets.env (gitignored) and export it before launching claude, for example:

set -a; source arch/secrets.env; set +a   # export everything in the file
claude
Source 3 files
hooks/register.tsx 442 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextBreakdown, SessionUsage } from 'claude-code'
3
4import type { Card, Range, ReportLine, Strip } from '../types'
5import {
6  aggregate,
7  cardRows,
8  dayOf,
9  fmtTok,
10  fmtUsd,
11  newLedger,
12  parseGitStatus,
13  parseOffset,
14  parseRange,
15  record,
16  reportRow,
17  rowWidth,
18  sinceDay,
19  contextRow,
20  legendLine,
21  limitParts,
22  taskLabel,
23  totalTokens,
24  turnLine,
25} from './ledger'
26import type { AgentRun, Budget, Part, SessionLedger } from './ledger'
27
28type Engine = EngineInterface
29
30const PANE = 'token-ledger'
31const CONSULT = 'mcp__consult__consult'
32const RANGES: readonly Range[] = ['today', '7d', '30d']
33const NO_TASK = '(no prompt)'
34
35const strip = atom({ plugin: 'token-ledger', key: 'strip' } as const, null)
36const report = atom({ plugin: 'token-ledger', key: 'report' } as const, null)
37const card = atom({ plugin: 'token-ledger', key: 'card' } as const, null)
38
39/** A Part's style as Text props, leaving out what it does not set. */
40function paint(p: Part) {
41  return {
42    ...(p.color === undefined ? {} : { color: p.color }),
43    ...(p.bg === undefined ? {} : { backgroundColor: p.bg }),
44    ...(p.dim === true ? { dimColor: true } : {}),
45    ...(p.bold === true ? { bold: true } : {}),
46  }
47}
48
49type SpendEntry = { ts: string; model: string; input_tokens: number; output_tokens: number; usd: number }
50
51// Module state: a hot reload starts it over, and the ledger reloads from its file.
52let home = ''
53let offsetMin = 0
54let isInteractive = false
55let ledger: SessionLedger | undefined
56let turn: { id: string; usdAt?: number; agents: AgentRun[] } | undefined
57let saving: Promise<void> = Promise.resolve()
58let hasWarned = false
59let budget: Budget = { good: 150_000, limit: 300_000 }
60const spawns = new Map<string, { type: string; task: string; turnId?: string }>()
61const bookedConsults = new Set<string>()
62
63async function folder($: Engine): Promise<string> {
64  if (home === '') home = (await $.env.get('HOME')) ?? ''
65  return `${home}/.claude/token-ledger`
66}
67
68async function localOffset($: Engine): Promise<number> {
69  try {
70    const { exitCode, stdout } = await $.process.run(['date', '+%z'])
71    const offset = exitCode === 0 ? parseOffset(stdout) : undefined
72    if (offset !== undefined) return offset
73  } catch {
74    // fall through to the environment's own idea of the zone
75  }
76  return -new Date().getTimezoneOffset()
77}
78
79/** This session's ledger; a new id (start, /clear, reload) picks up its file. */
80async function bookFor($: Engine): Promise<SessionLedger> {
81  const id = await $.session.id()
82  if (ledger?.sessionId === id) return ledger
83  let saved: SessionLedger | undefined
84  try {
85    const parsed = JSON.parse(await $.fs.read(`${await folder($)}/${id}.json`)) as SessionLedger
86    if (parsed.v === 1) saved = parsed
87  } catch {
88    // nothing booked for this session yet
89  }
90  const root = await $.session.root()
91  ledger = saved ?? newLedger(id, root.split('/').filter(Boolean).pop() ?? root)
92  return ledger
93}
94
95/** Writes the ledger; chained, so an older snapshot never lands after a newer one. */
96function save($: Engine): Promise<void> {
97  const book = ledger
98  if (book === undefined) return saving
99  saving = saving
100    .then(async () => {
101      book.updatedAt = await $.clock.now()
102      await $.fs.write(`${await folder($)}/${book.sessionId}.json`, JSON.stringify(book))
103    })
104    .catch((error: unknown) => {
105      if (hasWarned) return
106      hasWarned = true
107      $.ui.toast(`token-ledger: could not save the ledger (${String(error)})`)
108    })
109  return saving
110}
111
112type Figures = Pick<SessionUsage, 'rateLimits' | 'cost'> & {
113  context: SessionUsage['context'] & { breakdown?: SessionContextBreakdown }
114}
115
116/** Redraws the band; a reading without a breakdown keeps the last one's categories. */
117async function showUsage($: Engine, usage: Figures) {
118  const at = await $.clock.now()
119  const limits = usage.rateLimits.map(l => ({ kind: l.kind, pct: l.percentUsed, resetsAt: l.resetsAt }))
120  const b = usage.context.breakdown
121  await update($, strip, last => {
122    const context =
123      b === undefined
124        ? {
125            ctxPct: usage.context.percent ?? last?.ctxPct,
126            ctxTokens: usage.context.tokens ?? last?.ctxTokens,
127            ctxWindow: last?.ctxWindow ?? usage.context.window,
128            compactAt: last?.compactAt,
129            categories: last?.categories ?? [],
130          }
131        : {
132            ctxPct: b.percentage,
133            ctxTokens: b.totalTokens,
134            ctxWindow: b.rawMaxTokens,
135            compactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
136            categories: b.categories.map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind })),
137          }
138    const next: Strip = { at, ...context, limits, usd: usage.cost?.usd }
139    return next
140  })
141}
142
143async function startingEffort($: Engine): Promise<string | undefined> {
144  const fromEnv = await $.env.get('CLAUDE_EFFORT')
145  if (fromEnv !== undefined && fromEnv !== '') return fromEnv
146  const settings = (await $.settings.read()) as { effortLevel?: unknown }
147  return typeof settings.effortLevel === 'string' ? settings.effortLevel : undefined
148}
149
150/** Updates the card: `patch` from the turn, branch and dirtiness from git. Never throws: the card is decoration. */
151async function showCard($: Engine, patch: Partial<Card> = {}) {
152  try {
153    const root = await $.session.root()
154    let where: Partial<Card> = { repo: root.split('/').pop() ?? root, isDirty: false }
155    const top = await $.process.run(['git', '-C', root, 'rev-parse', '--show-toplevel'])
156    if (top.exitCode === 0) {
157      const status = await $.process.run(['git', '-C', root, 'status', '--porcelain=v2', '--branch', '--untracked-files=no'])
158      where = { repo: top.stdout.trim().split('/').pop() ?? root, ...parseGitStatus(status.stdout) }
159    }
160    const fallback = await $.session.model()
161    const last = await read($, card)
162    // Effort before any turn has reported one: what the engine hands its tools, else settings.
163    const effort = last?.effort ?? patch.effort ?? (await startingEffort($))
164    await update($, card, held => {
165      const next: Card = {
166        model: held?.model ?? fallback,
167        isDirty: false,
168        ...held,
169        ...(effort === undefined ? {} : { effort }),
170        ...where,
171        ...patch,
172      }
173      return next
174    })
175  } catch {
176    // no git, or the session's figures were not to be had: the card keeps what it showed
177  }
178}
179
180/** A full reading: the window broken down as /context does, estimated locally (no API call). */
181async function measure($: Engine) {
182  await showUsage($, await $.session.usage({ breakdown: 'summary' }))
183}
184
185async function showReport($: Engine, range: Range) {
186  await save($)
187  const now = await $.clock.now()
188  const since = sinceDay(range, now, offsetMin)
189  // A file untouched since the day before `since` holds nothing in range.
190  const cutoff = Date.parse(since) - 86_400_000
191  const dir = await folder($)
192  const entries = await $.fs.list(dir).catch(() => [])
193  const books = await Promise.all(
194    entries
195      .filter(f => f.kind === 'file' && f.name.endsWith('.json') && f.mtimeMs >= cutoff)
196      .map(f =>
197        $.fs
198          .read(`${dir}/${f.name}`)
199          .then(text => JSON.parse(text) as SessionLedger)
200          .catch(() => undefined),
201      ),
202  )
203  const ledgers = books.filter((b): b is SessionLedger => b?.v === 1)
204  await update($, report, () => aggregate(ledgers, range, since))
205}
206
207/** Books the consult call that just ran from the consult server's own spend log. */
208async function bookConsult($: Engine, startedAt: number) {
209  const log = await $.fs.read(`${home}/.consult-mcp/spend.jsonl`)
210  const lines = log.trim().split('\n').reverse()
211  for (const line of lines) {
212    const entry = JSON.parse(line) as SpendEntry
213    // Older than the call: nothing was billed (blocked by the guardrail, or failed).
214    if (Date.parse(entry.ts) < startedAt - 5_000) return
215    const key = `${entry.ts}|${entry.usd}`
216    if (bookedConsults.has(key)) continue
217    bookedConsults.add(key)
218    const book = await bookFor($)
219    record(book, {
220      day: dayOf(Date.parse(entry.ts), offsetMin),
221      task: book.task ?? NO_TASK,
222      model: entry.model,
223      agent: 'consult',
224      usage: {
225        input_tokens: entry.input_tokens,
226        output_tokens: entry.output_tokens,
227        cache_read_input_tokens: 0,
228        cache_creation_input_tokens: 0,
229      },
230      usd: entry.usd,
231    })
232    await save($)
233    return
234  }
235}
236
237export const register: Register = (on, options) => {
238  budget = { good: Number(options.goodTokens ?? budget.good), limit: Number(options.limitTokens ?? budget.limit) }
239
240  on('session.start', async ($, e, next) => {
241    isInteractive = e.isInteractive
242    offsetMin = await localOffset($)
243    await bookFor($)
244    await $.command.register({
245      name: 'tokens',
246      description: 'Where the tokens went, by project, task, model and agent',
247      argumentHint: '[today|7d|30d]',
248      immediate: true,
249    })
250    await measure($)
251    await showCard($)
252    // Keeps the reset countdowns and the branch current between turns; both are cheap.
253    $.clock.every(60_000, () => {
254      void $.session.usage().then(usage => showUsage($, usage))
255      void showCard($)
256    })
257    return next(e)
258  })
259
260  on('session.measure', async ($, e, next) => {
261    await (e.changed.includes('context') ? measure($) : showUsage($, e))
262    return next(e)
263  })
264
265  on('prompt.submit', async ($, e, next) => {
266    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
267      const book = await bookFor($)
268      book.task = taskLabel(e.text, book.task)
269    }
270    return next(e)
271  })
272
273  on('turn.start', async ($, e, next) => {
274    const { cost } = await $.session.usage()
275    turn = { id: e.turnId, usdAt: cost?.usd, agents: [] }
276    return next(e)
277  })
278
279  on('agent.spawn', async ($, e, next) => {
280    const spawned = await next(e)
281    if (spawned.agentId !== undefined) {
282      const book = await bookFor($)
283      spawns.set(spawned.agentId, { type: e.subagentType, task: book.task ?? NO_TASK, turnId: turn?.id })
284    }
285    return spawned
286  }).catch(($, e, next) => next(e))
287
288  // The settings hooks' Stop carries the turn's effort, after any downgrade for the model.
289  on('classic.Stop', async ($, e, next) => {
290    const level = e.effort?.level
291    if (level !== undefined) await showCard($, { effort: level })
292    return next(e)
293  })
294
295  on('turn.complete', async ($, e, next) => {
296    const answered = await next(e)
297    const book = await bookFor($)
298    const day = dayOf(await $.clock.now(), offsetMin)
299    const usage = e.usage
300
301    if (e.agentId !== undefined) {
302      const spawn = spawns.get(e.agentId)
303      if (usage !== undefined) {
304        record(book, {
305          day,
306          task: spawn?.task ?? book.task ?? NO_TASK,
307          model: usage.model,
308          agent: spawn?.type ?? 'subagent',
309          usage,
310        })
311        if (spawn !== undefined && turn !== undefined && spawn.turnId === turn.id) {
312          turn.agents.push({ type: spawn.type, model: usage.model, tokens: totalTokens(usage) })
313        }
314        await save($)
315      }
316      return answered
317    }
318
319    if (usage !== undefined) {
320      record(book, { day, task: book.task ?? NO_TASK, model: usage.model, agent: 'main', usage })
321      await save($)
322    }
323    const { cost } = await $.session.usage()
324    const usd = cost !== undefined && turn?.usdAt !== undefined ? cost.usd - turn.usdAt : undefined
325    const line = turnLine(usage, turn?.agents ?? [], usd)
326    turn = undefined
327    await showCard($, usage === undefined ? {} : { model: usage.model })
328    // A headless run prints the answer; leave it alone there.
329    return isInteractive && line !== undefined ? { ...answered, text: line } : answered
330  })
331
332  on('tool.call', { tool: CONSULT }, async ($, e, next) => {
333    const startedAt = await $.clock.now()
334    const ran = await next(e)
335    try {
336      await bookConsult($, startedAt)
337    } catch {
338      // the spend log is the consult server's; a missing or odd line books nothing
339    }
340    return ran
341  }).catch(($, e, next) => next(e))
342
343  on('command.run', { command: 'tokens' }, async ($, e) => {
344    await showReport($, parseRange(e.args))
345    await $.ui.open({ id: PANE, title: 'Tokens' })
346    return {}
347  })
348
349  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
350    const figures = await read($, strip)
351    if (e.props.hasSurvey || figures === null || figures.categories.length === 0) return next(e)
352    const { Box, Text } = $.ui.resolve(e)
353    const line = (parts: readonly Part[]) => (
354      <Text wrap="truncate-end">
355        {parts.map(p => (
356          <Text {...paint(p)}>{p.text}</Text>
357        ))}
358      </Text>
359    )
360    const frame = { borderStyle: 'round', borderColor: 'inactive', paddingX: 1 } as const
361    // Three rows, framed where the slot has two more to spare; the card beside them
362    // where there is width for both.
363    const isFramed = e.props.maxRows >= 5
364    const where = await read($, card)
365    const cardLines = where === null ? [] : cardRows(where)
366    const cardWidth = Math.max(0, ...cardLines.map(rowWidth)) + 4
367    const hasCard = isFramed && cardLines.length > 0 && e.props.bodyColumns >= cardWidth + 61
368    const width = Math.min(e.props.bodyColumns - (hasCard ? cardWidth + 1 : 0), 110)
369    const inner = isFramed ? width - 4 : width
370    const limits = limitParts(figures, inner)
371
372    return (
373      <Box gap={1}>
374        <Box key="context" flexDirection="column" width={width} {...(isFramed ? frame : {})}>
375          {line(contextRow(figures, inner, budget))}
376          {line(legendLine(figures, inner, budget))}
377          {limits.length > 0 && line(limits)}
378        </Box>
379        {hasCard && (
380          <Box key="card" flexDirection="column" width={cardWidth} {...frame}>
381            {cardLines.map(line)}
382          </Box>
383        )}
384      </Box>
385    )
386  })
387
388  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
389    const { Box, Button, Text } = $.ui.resolve(e)
390    const r = await read($, report)
391    if (r === null) return <Text dimColor>Counting…</Text>
392    const columns = e.props.bodyColumns
393    const cached = r.input > 0 ? Math.round((r.cacheRead / r.input) * 100) : 0
394    const section = (title: string, lines: readonly ReportLine[], total: number, limit: number) => (
395      <Box flexDirection="column" marginTop={1}>
396        <Text bold>{title}</Text>
397        {lines.length === 0 ? (
398          <Text dimColor>  nothing yet</Text>
399        ) : (
400          lines.slice(0, limit).map(line => <Text wrap="truncate-end">{reportRow(line, total, columns)}</Text>)
401        )}
402      </Box>
403    )
404
405    return (
406      <Box flexDirection="column">
407        <Box gap={1}>
408          {RANGES.map((range, i) => (
409            <Button
410              key={`range-${range}`}
411              label={range}
412              hotkey={String(i + 1)}
413              variant={range === r.range ? 'primary' : 'secondary'}
414              onPress={() => showReport($, range)}
415            />
416          ))}
417        </Box>
418        <Box flexDirection="column" marginTop={1}>
419          <Text>
420            <Text bold>{fmtUsd(r.usd)}</Text> API-equivalent · {fmtTok(r.tokens)} tokens · {r.sessions}{' '}
421            {r.sessions === 1 ? 'session' : 'sessions'} since {r.since}
422          </Text>
423          <Text dimColor>
424            {fmtTok(r.input)} in ({cached}% cached) · {fmtTok(r.output)} out · $ estimated from list prices
425          </Text>
426        </Box>
427        {section('By project', r.byProject, r.usd, 6)}
428        {section('By task', r.byTask, r.usd, 10)}
429        {section('By model', r.byModel, r.usd, 6)}
430        {section('By agent', r.byAgent, r.usd, 8)}
431        {r.consult.calls > 0 &&
432          section(
433            `Fable consults · off-plan, billed · ${r.consult.calls} ${r.consult.calls === 1 ? 'call' : 'calls'} · ${fmtUsd(r.consult.usd)}`,
434            r.consult.byTask,
435            r.consult.usd,
436            5,
437          )}
438      </Box>
439    )
440  })
441}
442
hooks/ledger.ts 517 lines
1// Pure accounting: prices, a session's ledger rows, the cross-session report, and
2// the text the strip, the turn line and the pane draw. Nothing here touches `$`.
3
4import type { Card, Range, Report, ReportLine, Strip, StripCategory } from '../types'
5
6/** The four token counts of one API call or a turn's sum, as the API spells them. */
7export type Usage = {
8  input_tokens: number
9  output_tokens: number
10  cache_read_input_tokens: number
11  cache_creation_input_tokens: number
12}
13
14/** Tokens and estimated $ for one day × task × model × agent of a session. */
15export type Row = {
16  day: string
17  task: string
18  model: string
19  /** `main`, a subagent type, or `consult` (Fable through the consult MCP, off-plan). */
20  agent: string
21  /** Turns for main, runs for a subagent, calls for a consult. */
22  runs: number
23  input: number
24  output: number
25  cacheRead: number
26  cacheWrite: number
27  usd: number
28}
29
30/** One session's file under ~/.claude/token-ledger/. Only that session writes it. */
31export type SessionLedger = {
32  v: 1
33  sessionId: string
34  project: string
35  /** The label the next turn is booked under (from the last real prompt). */
36  task?: string
37  updatedAt: number
38  rows: Row[]
39}
40
41// $ per million tokens: input, output, cache read. Cache writes bill at 2x input,
42// since Claude Code writes its prompt cache with the 1-hour TTL. Rates from the
43// claude-api skill's model table (cached 2026-09-25). First prefix match wins, so
44// the longer ids come first and a dated id still prices.
45const PRICES: readonly [prefix: string, input: number, output: number, cacheRead: number][] = [
46  ['claude-fable-5-1', 10, 50, 0.25],
47  ['claude-fable-5', 10, 50, 1],
48  ['claude-opus-5-5', 4, 20, 0.2],
49  ['claude-opus-5', 5, 25, 0.5],
50  ['claude-opus-4', 5, 25, 0.5],
51  ['claude-sonnet-5-5', 2, 10, 0.2],
52  ['claude-sonnet-5', 2, 10, 0.2],
53  ['claude-sonnet-4', 3, 15, 0.3],
54  ['claude-haiku-4-5', 1, 5, 0.1],
55]
56
57/** API-equivalent $ of `usage` on `model`; 0 for a model with no known rate. */
58export function costOf(model: string, usage: Usage): number {
59  const price = PRICES.find(([prefix]) => model.startsWith(prefix))
60  if (price === undefined) return 0
61  const [, input, output, cacheRead] = price
62  return (
63    (usage.input_tokens * input +
64      usage.cache_creation_input_tokens * input * 2 +
65      usage.cache_read_input_tokens * cacheRead +
66      usage.output_tokens * output) /
67    1e6
68  )
69}
70
71export function totalTokens(usage: Usage): number {
72  return (
73    usage.input_tokens +
74    usage.output_tokens +
75    usage.cache_read_input_tokens +
76    usage.cache_creation_input_tokens
77  )
78}
79
80/** `+0300` → 180. */
81export function parseOffset(z: string): number | undefined {
82  const m = /^([+-])(\d{2})(\d{2})$/.exec(z.trim())
83  if (m === null) return undefined
84  const minutes = Number(m[2]) * 60 + Number(m[3])
85  return m[1] === '-' ? -minutes : minutes
86}
87
88/** The local calendar day of `ms`, `offsetMin` east of UTC. */
89export function dayOf(ms: number, offsetMin: number): string {
90  return new Date(ms + offsetMin * 60_000).toISOString().slice(0, 10)
91}
92
93// A short reply that steers the running task rather than starting a new one.
94const STEER =
95  /^(y|yes|yep|yeah|no|nope|ok|okay|k|sure|go|go on|go ahead|do it|continue|proceed|next|lets? (go|do|continue)|thanks|thank you|ty|lgtm|nice|great|cool|done)\b/i
96
97/** The task a prompt is booked under: its first line, or `previous` for a steer. */
98export function taskLabel(text: string, previous: string | undefined): string {
99  const line = (text.split('\n').find(l => l.trim() !== '') ?? '').trim().replace(/\s+/g, ' ')
100  const isSteer = line.length < 12 || (line.length < 40 && STEER.test(line))
101  if (previous !== undefined && isSteer) return previous
102  if (line === '') return '(no prompt)'
103  return line.length > 48 ? `${line.slice(0, 47)}…` : line
104}
105
106export function newLedger(sessionId: string, project: string): SessionLedger {
107  return { v: 1, sessionId, project, updatedAt: 0, rows: [] }
108}
109
110/** Books one turn, run or call into `ledger`; `usd` overrides the price table. */
111export function record(
112  ledger: SessionLedger,
113  entry: { day: string; task: string; model: string; agent: string; usage: Usage; usd?: number },
114): void {
115  const { day, task, model, agent, usage } = entry
116  let row = ledger.rows.find(
117    r => r.day === day && r.task === task && r.model === model && r.agent === agent,
118  )
119  if (row === undefined) {
120    row = { day, task, model, agent, runs: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0 }
121    ledger.rows.push(row)
122  }
123  row.runs += 1
124  row.input += usage.input_tokens
125  row.output += usage.output_tokens
126  row.cacheRead += usage.cache_read_input_tokens
127  row.cacheWrite += usage.cache_creation_input_tokens
128  row.usd += entry.usd ?? costOf(model, usage)
129}
130
131const RANGE_DAYS: Record<Range, number> = { today: 1, '7d': 7, '30d': 30 }
132
133export function parseRange(args: string): Range {
134  const word = args.trim().toLowerCase()
135  if (word === '7d' || word === 'week') return '7d'
136  if (word === '30d' || word === 'month') return '30d'
137  return 'today'
138}
139
140/** The first day `range` covers, counting today. */
141export function sinceDay(range: Range, now: number, offsetMin: number): string {
142  return dayOf(now - (RANGE_DAYS[range] - 1) * 86_400_000, offsetMin)
143}
144
145function rowTokens(r: Row): number {
146  return r.input + r.output + r.cacheRead + r.cacheWrite
147}
148
149function breakdown(rows: { key: string; row: Row }[]): ReportLine[] {
150  const lines = new Map<string, ReportLine>()
151  for (const { key, row } of rows) {
152    const line = lines.get(key) ?? { name: key, tokens: 0, usd: 0 }
153    line.tokens += rowTokens(row)
154    line.usd += row.usd
155    lines.set(key, line)
156  }
157  return [...lines.values()].sort((a, b) => b.usd - a.usd || b.tokens - a.tokens)
158}
159
160/** Folds every session's rows from `since` on into the /tokens report. */
161export function aggregate(ledgers: SessionLedger[], range: Range, since: string): Report {
162  const plan: { project: string; row: Row }[] = []
163  const consults: Row[] = []
164  let sessions = 0
165  for (const ledger of ledgers) {
166    const rows = ledger.rows.filter(r => r.day >= since)
167    if (rows.length > 0) sessions += 1
168    for (const row of rows) {
169      if (row.agent === 'consult') consults.push(row)
170      else plan.push({ project: ledger.project, row })
171    }
172  }
173  const sum = (pick: (r: Row) => number) => plan.reduce((n, { row }) => n + pick(row), 0)
174  return {
175    range,
176    since,
177    sessions,
178    tokens: sum(rowTokens),
179    input: sum(r => r.input + r.cacheRead + r.cacheWrite),
180    cacheRead: sum(r => r.cacheRead),
181    output: sum(r => r.output),
182    usd: sum(r => r.usd),
183    byProject: breakdown(plan.map(({ project, row }) => ({ key: project, row }))),
184    byTask: breakdown(plan.map(({ row }) => ({ key: row.task, row }))),
185    byModel: breakdown(plan.map(({ row }) => ({ key: shortModel(row.model), row }))),
186    byAgent: breakdown(plan.map(({ row }) => ({ key: row.agent, row }))),
187    consult: {
188      calls: consults.reduce((n, r) => n + r.runs, 0),
189      tokens: consults.reduce((n, r) => n + rowTokens(r), 0),
190      usd: consults.reduce((n, r) => n + r.usd, 0),
191      byTask: breakdown(consults.map(row => ({ key: row.task, row }))),
192    },
193  }
194}
195
196// ── Text ────────────────────────────────────────────────────────────────────
197
198export function fmtTok(n: number): string {
199  const trim = (fixed: string) => fixed.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
200  if (n < 1000) return String(Math.round(n))
201  if (n < 1e6) return `${trim((n / 1e3).toFixed(n < 1e4 ? 1 : 0))}k`
202  return `${trim((n / 1e6).toFixed(n < 1e7 ? 2 : 1))}M`
203}
204
205export function fmtUsd(usd: number): string {
206  if (usd > 0 && usd < 0.005) return '<$0.01'
207  return `$${usd < 100 ? usd.toFixed(2) : usd.toFixed(0)}`
208}
209
210/** `claude-opus-5-5` → `opus-5.5`, `claude-haiku-4-5-20251001` → `haiku-4.5`. */
211export function shortModel(id: string): string {
212  return id
213    .replace(/^claude-/, '')
214    .replace(/-\d{8}$/, '')
215    .replace(/\[.*\]$/, '')
216    .replace(/-(\d+)-(\d+)$/, '-$1.$2')
217}
218
219/** Time left until `iso`, from `now`: `45m`, `2h14m`, `3d4h`. */
220export function fmtUntil(iso: string, now: number): string | undefined {
221  const ms = Date.parse(iso) - now
222  if (!Number.isFinite(ms)) return undefined
223  const minutes = Math.max(0, Math.round(ms / 60_000))
224  if (minutes < 60) return `${minutes}m`
225  const hours = Math.floor(minutes / 60)
226  if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
227  return `${Math.floor(hours / 24)}d${hours % 24}h`
228}
229
230/** A subagent run that finished inside the turn that spawned it. */
231export type AgentRun = { type: string; model: string; tokens: number }
232
233/** The line under an answer: `↳ 84.2k in (91% cached) · 2.1k out · Explore×2 haiku-4.5 18k · $0.42`. */
234export function turnLine(
235  usage: Usage | undefined,
236  agents: readonly AgentRun[],
237  usd: number | undefined,
238): string | undefined {
239  const parts: string[] = []
240  if (usage !== undefined) {
241    const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
242    const cached = input > 0 ? Math.round((usage.cache_read_input_tokens / input) * 100) : 0
243    parts.push(`${fmtTok(input)} in (${cached}% cached)`, `${fmtTok(usage.output_tokens)} out`)
244  }
245  const groups = new Map<string, { label: string; n: number; tokens: number }>()
246  for (const run of agents) {
247    const key = `${run.type}\u0000${run.model}`
248    const group = groups.get(key) ?? { label: run.type, n: 0, tokens: 0 }
249    group.n += 1
250    group.tokens += run.tokens
251    groups.set(key, group)
252  }
253  for (const [key, { label, n, tokens }] of groups) {
254    const model = shortModel(key.split('\u0000')[1] ?? '')
255    parts.push(`${label}${n > 1 ? `×${n}` : ''} ${model} ${fmtTok(tokens)}`)
256  }
257  if (parts.length === 0) return undefined
258  if (usd !== undefined && usd > 0) parts.push(fmtUsd(usd))
259  return `↳ ${parts.join(' · ')}`
260}
261
262/** One run of text in the band, its colors theme keys. */
263export type Part = { text: string; color?: string; bg?: string; dim?: boolean; bold?: boolean }
264
265// Short names and a palette with one hue per category, by the row's /context label.
266// Unknown rows keep the engine's name and theme colour.
267const CATEGORY_STYLE: Record<string, { name: string; color: string }> = {
268  'system prompt': { name: 'system', color: '#7aa2f7' },
269  'system tools': { name: 'tools', color: '#7dcfff' },
270  'mcp tools': { name: 'mcp', color: '#bb9af7' },
271  'mcp server instructions': { name: 'mcp', color: '#bb9af7' },
272  'custom agents': { name: 'agents', color: '#9ece6a' },
273  'memory files': { name: 'memory', color: '#e0af68' },
274  skills: { name: 'skills', color: '#f7a1c4' },
275  'slash commands': { name: 'commands', color: '#73daca' },
276  messages: { name: 'messages', color: '#ff9e64' },
277}
278
279/** Green, amber, red by how full a window is. */
280export function levelColor(pct: number): string {
281  if (pct >= 90) return 'error'
282  if (pct >= 70) return 'warning'
283  return 'success'
284}
285
286function styleOf(c: StripCategory): { name: string; color: string } {
287  const lower = c.name.toLowerCase()
288  return CATEGORY_STYLE[lower] ?? { name: lower, color: c.color }
289}
290
291function width(parts: readonly Part[]): number {
292  return parts.reduce((n, p) => n + p.text.length, 0)
293}
294
295/** Joins runs of one style, so a bar is a handful of Texts rather than a cell each. */
296function merge(cells: readonly Part[]): Part[] {
297  const parts: Part[] = []
298  for (const cell of cells) {
299    const last = parts[parts.length - 1]
300    if (last !== undefined && last.color === cell.color && last.dim === cell.dim && last.bg === cell.bg) {
301      last.text += cell.text
302    } else {
303      parts.push({ ...cell })
304    }
305  }
306  return parts
307}
308
309/** How big a session should get: lean under `good`, too heavy past `limit`. */
310export type Budget = { good: number; limit: number }
311
312/** The budget's limit, or the window where the window is smaller. */
313function ceiling(strip: Strip, budget: Budget): number {
314  return Math.min(budget.limit, strip.ctxWindow)
315}
316
317/** Green while lean, amber on the way to the limit, red past it. */
318export function budgetColor(tokens: number, strip: Strip, budget: Budget): string {
319  if (tokens >= ceiling(strip, budget)) return 'error'
320  if (tokens >= budget.good) return 'warning'
321  return 'success'
322}
323
324/** The fill as a share of the limit, not of the window. */
325export function badgePart(strip: Strip, budget: Budget): Part {
326  const tokens = strip.ctxTokens ?? 0
327  const pct = Math.round((tokens / ceiling(strip, budget)) * 100)
328  return { text: ` ${pct}% `, color: 'inverseText', bg: budgetColor(tokens, strip, budget), bold: true }
329}
330
331/**
332 * The budget as `cells` blocks: each used category in its own colour (one block at
333 * least, so a small one still shows), hatched red past the limit, the rest a grey track,
334 * marked at the lean size, the limit and, inside the bar, the compaction point.
335 */
336export function contextBar(strip: Strip, cells: number, budget: Budget): Part[] {
337  if (cells <= 0 || strip.ctxWindow <= 0) return []
338  const tokens = strip.ctxTokens ?? 0
339  const limit = ceiling(strip, budget)
340  const scale = Math.max(limit, tokens)
341  const cellOf = (t: number) => Math.min(cells - 1, Math.round((t / scale) * cells))
342  const used = strip.categories.filter(c => c.kind === 'used' && c.tokens > 0)
343  const counts = used.map(c => Math.max(1, Math.round((c.tokens / scale) * cells)))
344  for (let over = counts.reduce((a, b) => a + b, 0) - cells; over > 0; over -= 1) {
345    const widest = counts.indexOf(Math.max(...counts))
346    counts[widest] = (counts[widest] ?? 1) - 1
347  }
348  const row: Part[] = []
349  used.forEach((c, i) => {
350    const { color } = styleOf(c)
351    for (let k = 0; k < (counts[i] ?? 0); k += 1) row.push({ text: '█', color })
352  })
353  const usedCells = row.length
354  if (tokens > limit) {
355    for (let k = cellOf(limit); k < usedCells; k += 1) row[k] = { text: '▓', color: 'error' }
356  }
357  while (row.length < cells) row.push({ text: '░', color: 'inactive' })
358  const marks: [number, Part][] = [
359    [budget.good, { text: '┊', color: 'warning' }],
360    [limit, { text: '┃', color: 'error' }],
361  ]
362  if (strip.compactAt !== undefined && strip.compactAt < scale) marks.push([strip.compactAt, { text: '▏', color: 'claude' }])
363  for (const [at, mark] of marks) {
364    const cell = cellOf(at)
365    if (cell >= usedCells) row[cell] = mark
366  }
367  return merge(row)
368}
369
370/** `◆ 288k / 300k  █████████░░┊░░░┃  96%`: the fill, the bar in what is left, the badge. */
371export function contextRow(strip: Strip, columns: number, budget: Budget): Part[] {
372  const head: Part[] = [
373    { text: '◆ ', color: 'claude' },
374    { text: fmtTok(strip.ctxTokens ?? 0), bold: true },
375    { text: ` / ${fmtTok(ceiling(strip, budget))}  `, color: 'inactive' },
376  ]
377  const badge = badgePart(strip, budget)
378  const cells = columns - width(head) - badge.text.length - 1
379  return [...head, ...contextBar(strip, cells, budget), { text: ' ' }, badge]
380}
381
382/**
383 * One line of legend in bar order, then what is left of the budget (or how far
384 * over it): `■ messages 242k  ■ tools 27k  ■ left 12k`. Where it overflows
385 * `columns`, the smallest rows leave first.
386 */
387export function legendLine(strip: Strip, columns: number, budget: Budget): Part[] {
388  const left = ceiling(strip, budget) - (strip.ctxTokens ?? 0)
389  const items = [
390    ...strip.categories
391      .filter(c => c.kind === 'used' && c.tokens > 0)
392      .map(c => ({ ...styleOf(c), size: c.tokens, isRest: false })),
393    left >= 0
394      ? { name: 'left', color: 'inactive', size: left, isRest: true }
395      : { name: 'over', color: 'error', size: -left, isRest: true },
396  ].map(i => ({ ...i, tokens: fmtTok(i.size) }))
397  const fits = (shown: typeof items) =>
398    shown.reduce((n, i) => n + i.name.length + i.tokens.length + 5, 0) - 2 <= columns
399  const shown = [...items]
400  while (shown.length > 1 && !fits(shown)) {
401    const smallest = shown.filter(i => !i.isRest).reduce((a, b) => (b.size < a.size ? b : a))
402    shown.splice(shown.indexOf(smallest), 1)
403  }
404  const parts: Part[] = []
405  for (const item of shown) {
406    if (parts.length > 0) parts.push({ text: '  ' })
407    parts.push(
408      { text: '■ ', color: item.color },
409      { text: `${item.name} `, color: item.name === 'over' ? 'error' : 'inactive' },
410      { text: item.tokens, bold: true },
411    )
412  }
413  return parts
414}
415
416const EFFORT_COLORS: Record<string, string> = {
417  low: 'inactive',
418  medium: '#7aa2f7',
419  high: '#9ece6a',
420  xhigh: '#e0af68',
421  max: '#f7768e',
422}
423
424/** `claude-opus-5-5[1m]` → `Opus 5.5`; an alias or a display name keeps its words, capitalised. */
425export function modelName(id: string): string {
426  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
427  const words = m === null ? id : `${m[1]} ${m[2]}${m[3] === undefined ? '' : `.${m[3]}`}`
428  return words.charAt(0).toUpperCase() + words.slice(1)
429}
430
431/** Parses `git status --porcelain=v2 --branch`: the branch (or a short sha) and whether it is dirty. */
432export function parseGitStatus(out: string): { branch?: string; isDirty: boolean } {
433  let head: string | undefined
434  let oid: string | undefined
435  let isDirty = false
436  for (const line of out.split('\n')) {
437    if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length).trim()
438    else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length).trim()
439    else if (line.trim() !== '' && !line.startsWith('#')) isDirty = true
440  }
441  const branch = head === '(detached)' ? oid?.slice(0, 7) : head
442  return { ...(branch === undefined ? {} : { branch }), isDirty }
443}
444
445// Nerd Font glyphs (Material Design range): robot, folder, source branch.
446const ICON = { model: '\u{F06A9} ', repo: '\u{F024B} ', branch: '\u{F062C} ' }
447
448/** The card's three rows: model with its effort badge, repo, branch. */
449export function cardRows(card: Card): Part[][] {
450  const model: Part[] = [
451    { text: ICON.model, color: '#bb9af7' },
452    { text: modelName(card.model), color: '#bb9af7', bold: true },
453  ]
454  if (card.effort !== undefined) {
455    model.push(
456      { text: ' ' },
457      { text: ` ${card.effort} `, color: 'inverseText', bg: EFFORT_COLORS[card.effort] ?? 'inactive', bold: true },
458    )
459  }
460  const rows = [model]
461  if (card.repo !== undefined) rows.push([{ text: ICON.repo, color: '#7dcfff' }, { text: card.repo, color: '#7dcfff', bold: true }])
462  if (card.branch !== undefined) {
463    const row: Part[] = [{ text: ICON.branch, color: '#9ece6a' }, { text: card.branch, color: '#9ece6a', bold: true }]
464    if (card.isDirty) row.push({ text: '*', color: 'error', bold: true })
465    rows.push(row)
466  }
467  return rows
468}
469
470/** The width of a row of parts, in cells. */
471export function rowWidth(parts: readonly Part[]): number {
472  return width(parts)
473}
474
475/**
476 * `━━━━━━━━━━ 5% 4h18m   ━━━━━━━━━━ 39% 1d10h   $5.51`: the 5-hour window, then the
477 * weekly one (unlabelled, so the order is the label), each with the time to its
478 * reset, then the session's $. The meters shrink to fit `columns`.
479 */
480export function limitParts(strip: Strip, columns = Infinity): Part[] {
481  const order = (kind: string) => ['five_hour', 'seven_day'].indexOf(kind) >>> 0
482  const limits = [...strip.limits].sort((x, y) => order(x.kind) - order(y.kind))
483  const build = (meterCells: number): Part[] => {
484    const parts: Part[] = []
485    for (const limit of limits) {
486      if (parts.length > 0) parts.push({ text: '   ' })
487      const filled = Math.min(meterCells, Math.max(0, Math.round((limit.pct / 100) * meterCells)))
488      parts.push(
489        { text: '━'.repeat(filled), color: levelColor(limit.pct) },
490        { text: '━'.repeat(meterCells - filled), color: 'inactive', dim: true },
491        { text: ` ${Math.round(limit.pct)}%`, bold: true },
492      )
493      const left = limit.resetsAt === undefined ? undefined : fmtUntil(limit.resetsAt, strip.at)
494      if (left !== undefined) parts.push({ text: ` ${left}`, color: 'inactive' })
495    }
496    if (strip.usd !== undefined) {
497      if (parts.length > 0) parts.push({ text: '   ' })
498      parts.push({ text: fmtUsd(strip.usd), bold: true })
499    }
500    return parts
501  }
502  for (const meter of [10, 6]) {
503    const parts = build(meter)
504    if (width(parts) <= columns) return parts
505  }
506  return build(3)
507}
508
509/** A breakdown row padded to `columns`: name, tokens, share of `total` $, $. */
510export function reportRow(line: ReportLine, totalUsd: number, columns: number): string {
511  const share = totalUsd > 0 ? `${Math.round((line.usd / totalUsd) * 100)}%` : ''
512  const tail = `${fmtTok(line.tokens).padStart(7)}${share.padStart(6)}${fmtUsd(line.usd).padStart(9)}`
513  const room = Math.max(10, columns - tail.length - 2)
514  const name = line.name.length > room ? `${line.name.slice(0, room - 1)}…` : line.name
515  return `  ${name.padEnd(room)}${tail}`
516}
517
types/index.d.ts 61 lines
1/** One rate-limit window as the strip draws it. */
2export type StripLimit = { kind: string; pct: number; resetsAt?: string }
3
4/** One row of the context breakdown; `color` is the theme key /context draws it in. */
5export type StripCategory = {
6  name: string
7  tokens: number
8  color: string
9  kind: 'used' | 'free' | 'buffer' | 'deferred'
10}
11
12/** The figures the band above the prompt draws, as of `at`. */
13export type Strip = {
14  at: number
15  ctxPct?: number
16  ctxTokens?: number
17  ctxWindow: number
18  /** Where auto-compaction runs, in tokens; absent when it is off. */
19  compactAt?: number
20  categories: StripCategory[]
21  limits: StripLimit[]
22  usd?: number
23}
24
25/** What the card beside the band shows: the main loop's model and effort, and where it works. */
26export type Card = {
27  model: string
28  effort?: string
29  repo?: string
30  branch?: string
31  isDirty: boolean
32}
33
34export type Range = 'today' | '7d' | '30d'
35
36/** One row of a /tokens breakdown. */
37export type ReportLine = { name: string; tokens: number; usd: number }
38
39/** What the /tokens pane draws. */
40export type Report = {
41  range: Range
42  since: string
43  sessions: number
44  tokens: number
45  input: number
46  cacheRead: number
47  output: number
48  usd: number
49  byProject: ReportLine[]
50  byTask: ReportLine[]
51  byModel: ReportLine[]
52  byAgent: ReportLine[]
53  consult: { calls: number; tokens: number; usd: number; byTask: ReportLine[] }
54}
55
56declare module 'claude-code' {
57  interface PluginState {
58    'token-ledger': { strip: Strip | null; report: Report | null; card: Card | null }
59  }
60}
61