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

Dotfiles and machine setup for macOS and Arch Linux.
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.
cd mac
bash install.sh # interactive — pick what to install
bash env.sh # symlink dotfiles and configs
cd arch
bash env.sh # copy dotfiles and configs
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/):
| Script | Role | Side effects |
|---|---|---|
status.sh [--json] | drift discovery | none |
apply.sh <plan> [--apply] | execute a plan (adopt / resolve / install / ignore) | dry-run by default, secrets-gated |
pull.sh / push.sh | git wrappers | yes |
ignore.txt | noise/secret patterns status.sh skips | — |
Every write path runs a secrets scan before anything enters git.
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.
| Variable | Description |
|---|---|
DSD_CALENDAR_HOST | Calendar server IP |
DSD_DEV_HOST | Dev server IP |
CONSULT_ANTHROPIC_API_KEY | Anthropic API key for the consult MCP server (claude/mcp.json) |
TG_MCP_ALLOWLIST | Allowlist for the telegram MCP server (claude/mcp.json) |
cp arch/secrets.env.example arch/secrets.env
# edit with real values
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
claudehooks/register.tsx 442 lines1import { 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}
442hooks/ledger.ts 517 lines1// 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}
517types/index.d.ts 61 lines1/** 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