SLOPSHOPPER

token-ledger

A per-turn cost and token ledger: a pinned status line with the session cost, the last turn's tokens and the cache hit ratio, and /ledger for a table of the…

newpanecommandstatus
★ 4v0.1.0MITupdated 2026-09-14Arunjay4213/claude-mods/plugins/token-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-ledger
│ ┃ Token ledger ✕ › fix the failing auth test and add an audit log call │ ┃ # model in out cache r cache w hit … │ ┃ 1 opus-5-5 2.1k 1.5k 91k 4.3k 93% … ⏺ Read(src/auth.ts) │ ┃ -------------------------------------------… ⎿ Read 6 lines │ ┃ all 2.1k 1.5k 91k 4.3k 93% … ⏺ Update(src/auth.ts) │ ┃ session total $0.4200 ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ledger │ ⎿ token-ledger: Ledger open with 1 turn. /ledger closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ token-ledger: $0.4200 session · last turn $0.0000 · 97k in / 1.5k out · cache 93%

Draws

Pane · Token ledger
# model in out cache r cache w hit time cost 1 opus-5-5 2.1k 1.5k 91k 4.3k 93% 42s $0.0000 ------------------------------------------------------- all 2.1k 1.5k 91k 4.3k 93% 42s $0.0000 session total $0.4200
README

token-ledger

A per-turn cost and token ledger for Claude Code, built as a mod (function hooks).

It answers one question while you work: what did that turn just cost, and is the cache doing its job.

What it shows

A pinned status line under the prompt, updated when each turn ends:

$0.4799 session · last turn $0.2394 · 46k in / 5 out · cache 75%

in on this line is everything the answer was read over: fresh input, cache reads and cache writes together. cache is the share of that which came from the cache. Any figure the engine does not have is left out rather than guessed, so the line shortens instead of showing a zero or a blank.

A pane opened by /ledger, one row per turn, newest first:

  # model     in out cache r cache w hit time    cost
  2 fable-5-1  2   5     34k     12k 75% 2.8s $0.2394
  1 fable-5-1  2   4     34k     11k 75% 3.0s $0.2405
-----------------------------------------------------
all            4   9     68k     23k 75% 5.8s $0.4799
session total $0.4799

The in column here is the API's input_tokens on its own, the part that was neither read from the cache nor written to it. Add in, cache r and cache w to get the in figure on the status line.

Run /ledger again to close it. The pane docks beside the transcript from 110 terminal columns and sits inline above the prompt below that.

Columns are fitted to the pane width. When the pane is too narrow for all of them they are dropped in this order: duration, cache write, turn number, model, hit ratio, cache read, output tokens, input tokens. Cost is never dropped. At 44 terminal columns, for example, duration and cache write go and the rest still lines up.

Install

claude plugin marketplace add Arunjay4213/claude-mods
claude plugin install token-ledger@claude-mods

Mods are early access, so the module only loads when function hooks are switched on. Add this to ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Or set it for one run: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

Screenshot

The /ledger pane and the pinned cost line under the prompt

How the numbers are worked out

Token counts come straight from the turn.complete event: e.usage.input_tokens, output_tokens, cache_read_input_tokens and cache_creation_input_tokens, with e.usage.model for the model name and e.durationMs for the duration. These are the API's own counts for the turn, not an estimate.

Cache hit ratio is cache_read / (input + cache_read + cache_creation). When that denominator is zero the cell shows - rather than a made-up number.

Cost per turn is a difference, not a calculation. The session total comes from $.session.usage() as cost.usd, the same figure /cost and the status line report. The ledger reads it once when the session starts and once after every turn, and a turn's cost is the change between the two readings.

There is deliberately no pricing table in this plugin. Rates differ by model, by plan, by cache tier and by gateway, and they change. A table baked into a plugin would go stale and report confident wrong numbers, so the ledger only ever reports what the engine already priced.

Limitations

The first reading is taken at session.start, so a turn is only priced once the ledger has a baseline. If the engine reports no cost at all (a host that keeps no cost ledger), the cost column shows - and the status line drops the dollar figures.

Cost is attributed to whichever turn finished between two readings. A subagent's turn finishes inside the main turn, so its cost lands on the subagent's row and the main turn's row shows only what was left. Subagent rows are marked with * after the turn number and drawn dim.

The ledger keeps the last 200 turns. It is mirrored into $.store under the session id, so /ledger still has the history after /resume; a new session starts with an empty ledger rather than showing another session's turns. The pane draws the newest 40 rows and counts the rest in the totals.

The dollar figures are rounded for display (2 decimals from $1, 4 below it). The totals row adds the stored per-turn values, so it can differ from the session total by a fraction of a cent when rounding, or by more if some turns had no baseline. The session total line is always the engine's own figure.

Source 2 files
hooks/register.tsx 299 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type {
5  EngineInterface,
6  Register,
7  RenderElement,
8  TurnCompleteInput,
9} from 'claude-code'
10
11import { fmtUsd, rowOf, shortModel, statusText, table, type Row } from './format'
12
13// The ledger keeps one row per finished turn in memory and mirrors it into
14// $.store, so /ledger still has the history after a /resume.
15//
16// Per-turn cost is the change in $.session.usage().cost.usd between two
17// turn.complete events. No pricing table lives here: rates change, and a
18// guessed rate would be wrong quietly.
19
20const PANE_ID = 'ledger'
21const PANE_TITLE = 'Token ledger'
22const STORE_KEY = 'ledger'
23/** The store caps at 4 MiB for the whole plugin; 200 rows is far under it. */
24const MAX_ROWS = 200
25/** Rows drawn at once, newest first; older turns still count in the totals. */
26const MAX_DRAWN = 40
27
28/** Oldest first; the status line reads the last, the pane draws them reversed. */
29let rows: Row[] = []
30let sessionId: string | null = null
31/** cost.usd as last read: the baseline the next turn's delta is taken from. */
32let lastTotal: number | null = null
33let sessionUsd: number | null = null
34let turnNo = 0
35let isOpen = false
36/** Turns are recorded one at a time: two finishing together must not share a cost delta. */
37let recording: Promise<void> = Promise.resolve()
38
39/** The session's cost so far, or null where the host prices nothing. */
40async function costOf($: EngineInterface): Promise<number | null> {
41  try {
42    return (await $.session.usage()).cost?.usd ?? null
43  } catch {
44    return null
45  }
46}
47
48/** Pins the one-line summary. A status line that will not pin costs nothing. */
49function showStatus($: EngineInterface): void {
50  try {
51    $.ui.status(statusText(rows, sessionUsd))
52  } catch {
53    // nothing to do: the next turn tries again
54  }
55}
56
57/** Mirrors the ledger so /ledger still has it after a /resume. */
58async function save($: EngineInterface): Promise<void> {
59  try {
60    await $.store.set(STORE_KEY, { sessionId, rows })
61  } catch {
62    // the in-memory ledger is unaffected
63  }
64}
65
66/** Reads back this session's rows, or leaves the ledger empty. */
67async function restore($: EngineInterface): Promise<void> {
68  rows = []
69  turnNo = 0
70
71  try {
72    sessionId = await $.session.id()
73  } catch {
74    sessionId = null
75  }
76
77  let saved: unknown
78  try {
79    saved = await $.store.get(STORE_KEY)
80  } catch {
81    return
82  }
83
84  if (typeof saved !== 'object' || saved === null) return
85  const held = saved as Record<string, unknown>
86  const list = held['rows']
87
88  // Only this session's own rows: a new session starts empty, a resumed one
89  // (the same transcript id) picks its history back up.
90  if (sessionId === null || held['sessionId'] !== sessionId) return
91  if (!Array.isArray(list)) return
92
93  rows = list.map(rowOf).filter((r): r is Row => r !== null)
94  turnNo = rows.at(-1)?.n ?? 0
95}
96
97/** Adds the finished turn as a row, priced by what the session total moved. */
98async function record($: EngineInterface, e: TurnCompleteInput): Promise<void> {
99  const total = await costOf($)
100  if (total !== null) sessionUsd = total
101
102  const usage = e.usage
103
104  if (usage) {
105    const usd =
106      total !== null && lastTotal !== null ? Math.max(0, total - lastTotal) : null
107    if (total !== null) lastTotal = total
108
109    turnNo += 1
110    rows.push({
111      n: turnNo,
112      model: shortModel(usage.model),
113      inTok: usage.input_tokens,
114      outTok: usage.output_tokens,
115      cacheRead: usage.cache_read_input_tokens,
116      cacheWrite: usage.cache_creation_input_tokens,
117      ms: e.durationMs,
118      usd,
119      sub: e.agentId !== undefined,
120    })
121
122    if (rows.length > MAX_ROWS) rows = rows.slice(rows.length - MAX_ROWS)
123
124    await save($)
125  }
126  // A turn with no usage (aborted before an answer) keeps the baseline where it
127  // was, so whatever it cost lands in the next row and the totals still add up.
128
129  showStatus($)
130
131  if (isOpen) {
132    try {
133      $.ui.invalidate('ui.render')
134    } catch {
135      // the pane redraws at the next turn instead
136    }
137  }
138}
139
140/** After /clear or /resume the transcript is another one: start its ledger over. */
141async function reset($: EngineInterface): Promise<void> {
142  await restore($)
143  lastTotal = await costOf($)
144  sessionUsd = lastTotal
145  showStatus($)
146  if (isOpen) {
147    try {
148      $.ui.invalidate('ui.render')
149    } catch {
150      // the pane redraws at the next turn instead
151    }
152  }
153}
154
155export const register: Register = on => {
156  on('session.start', async ($, e, next) => {
157    const result = await next(e)
158
159    await restore($)
160
161    // The cost so far is the baseline the first turn's delta is measured from,
162    // so that figure is real even on a resumed session.
163    lastTotal = await costOf($)
164    sessionUsd = lastTotal
165
166    try {
167      await $.command.register({
168        name: 'ledger',
169        description: 'What each turn cost: tokens, cache, time and dollars',
170      })
171    } catch (error) {
172      $.ui.log(`token-ledger: /ledger not registered: ${error}`)
173    }
174
175    showStatus($)
176
177    return result
178  })
179
180  on('turn.complete', async ($, e, next) => {
181    const result = await next(e)
182
183    // Parallel subagents can finish in the same instant. Queue the bookkeeping so
184    // every row's cost is measured from the total the previous row left behind.
185    recording = recording.then(() => record($, e)).catch(() => undefined)
186    await recording
187
188    return result
189  })
190
191  on('command.run', { command: 'clear' }, async ($, e, next) => {
192    const result = await next(e)
193    await reset($)
194    return result
195  })
196
197  on('command.run', { command: 'resume' }, async ($, e, next) => {
198    const result = await next(e)
199    await reset($)
200    return result
201  })
202
203  on('command.run', { command: 'ledger' }, async ($, e, next) => {
204    if (isOpen) {
205      try {
206        await $.ui.close({ id: PANE_ID })
207      } catch {
208        // a pane that will not close is one a hook is holding open
209      }
210
211      isOpen = false
212
213      return { text: 'Ledger closed. /ledger opens it again.' }
214    }
215
216    const drawn = Math.min(rows.length, MAX_DRAWN)
217
218    try {
219      await $.ui.open({
220        id: PANE_ID,
221        title: PANE_TITLE,
222        rows: Math.max(4, Math.min(drawn + 4, 20)),
223      })
224    } catch {
225      return { text: 'The ledger pane would not open.' }
226    }
227
228    isOpen = true
229    const turns = `${rows.length} turn${rows.length === 1 ? '' : 's'}`
230
231    return {
232      text:
233        rows.length === 0
234          ? 'Ledger open. It fills in when a turn finishes.'
235          : `Ledger open with ${turns}. /ledger closes it.`,
236    }
237  })
238
239  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
240    const result = await next(e)
241    if (result.deny === undefined) isOpen = false
242    return result
243  })
244
245  on('ui.render', { component: 'Pane' }, ($, e, next) => {
246    if (e.requestId !== PANE_ID) return next(e)
247
248    const { Box, Text } = $.ui.resolve(e)
249    const columns = Math.max(12, e.props.bodyColumns)
250
251    if (rows.length === 0) {
252      return (
253        <Box flexDirection="column">
254          <Text dimColor wrap="truncate-end">
255            No turns yet. Send a prompt and the first row lands here.
256          </Text>
257        </Box>
258      ) as RenderElement
259    }
260
261    const shown = rows.slice(-MAX_DRAWN).reverse()
262    const laid = table(rows, shown, columns)
263    const hidden = rows.length - shown.length
264    const notes = [
265      sessionUsd === null
266        ? 'session cost unknown'
267        : `session total ${fmtUsd(sessionUsd)}`,
268      ...(hidden > 0 ? [`${hidden} older in the totals`] : []),
269      ...(shown.some(r => r.sub) ? ['* subagent turn'] : []),
270    ]
271
272    return (
273      <Box flexDirection="column">
274        <Text bold wrap="truncate-end">
275          {laid.head}
276        </Text>
277        {laid.body.map((line, i) => (
278          <Text
279            key={String(i)}
280            dimColor={shown[i]?.sub === true}
281            wrap="truncate-end"
282          >
283            {line}
284          </Text>
285        ))}
286        <Text dimColor wrap="truncate-end">
287          {'-'.repeat(Math.min(columns, laid.head.length))}
288        </Text>
289        <Text bold wrap="truncate-end">
290          {laid.totals}
291        </Text>
292        <Text dimColor wrap="truncate-end">
293          {notes.join(' · ')}
294        </Text>
295      </Box>
296    ) as RenderElement
297  })
298}
299
hooks/format.ts 193 lines
1// Numbers into the short strings the status line and the /ledger table show,
2// plus the table layout that drops columns when the pane is narrow.
3
4/** One finished turn, as the ledger keeps it (and mirrors into `$.store`). */
5export type Row = {
6  /** Turn number within this session, 1 upwards. */
7  n: number
8  /** Short model name, e.g. "opus-4-5". */
9  model: string
10  inTok: number
11  outTok: number
12  cacheRead: number
13  cacheWrite: number
14  /** Wall clock length of the turn, milliseconds. */
15  ms: number
16  /** Dollars this turn added to the session cost; null when unknown. */
17  usd: number | null
18  /** True for a subagent's turn (`e.agentId` was set). */
19  sub: boolean
20}
21
22const num = (v: unknown): number =>
23  typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0
24
25/** Rebuilds a Row from whatever the store gave back, or null if it is not one. */
26export function rowOf(value: unknown): Row | null {
27  if (typeof value !== 'object' || value === null) return null
28  const r = value as Record<string, unknown>
29  if (typeof r['n'] !== 'number') return null
30  const usd = r['usd']
31  return {
32    n: r['n'],
33    model: typeof r['model'] === 'string' ? r['model'] : '?',
34    inTok: num(r['inTok']),
35    outTok: num(r['outTok']),
36    cacheRead: num(r['cacheRead']),
37    cacheWrite: num(r['cacheWrite']),
38    ms: num(r['ms']),
39    usd: typeof usd === 'number' && Number.isFinite(usd) ? usd : null,
40    sub: r['sub'] === true,
41  }
42}
43
44/** "claude-opus-4-5-20251101" -> "opus-4-5"; anything unexpected is left alone. */
45export function shortModel(id: string): string {
46  const at = id.lastIndexOf('claude-')
47  const base = at >= 0 ? id.slice(at + 'claude-'.length) : id
48  const cut = base.replace(/-\d{6,8}$/, '').replace(/-v\d+(:\d+)?$/, '')
49  return cut === '' ? id : cut
50}
51
52export function fmtTokens(n: number): string {
53  if (!Number.isFinite(n) || n < 0) return '-'
54  if (n < 1000) return String(Math.round(n))
55  if (n < 10000) return `${(n / 1000).toFixed(1)}k`
56  if (n < 1e6) return `${Math.round(n / 1000)}k`
57  return `${(n / 1e6).toFixed(1)}M`
58}
59
60/** Dollars, with enough decimals that a cheap turn is not shown as $0.00. */
61export function fmtUsd(n: number | null): string {
62  if (n === null || !Number.isFinite(n)) return '-'
63  return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`
64}
65
66export function fmtMs(ms: number): string {
67  if (!Number.isFinite(ms) || ms < 0) return '-'
68  if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`
69  if (ms < 60000) return `${Math.round(ms / 1000)}s`
70  const m = Math.floor(ms / 60000)
71  const s = Math.round((ms % 60000) / 1000)
72  return `${m}m${String(s).padStart(2, '0')}s`
73}
74
75/** cache_read over everything the request was answered from; null when nothing was. */
76export function hitOf(inTok: number, read: number, write: number): number | null {
77  const total = inTok + read + write
78  return total > 0 ? Math.round((read / total) * 100) : null
79}
80
81const pct = (v: number | null): string => (v === null ? '-' : `${v}%`)
82
83const sum = (rows: readonly Row[], of: (r: Row) => number): number =>
84  rows.reduce((total, r) => total + of(r), 0)
85
86const sumUsd = (rows: readonly Row[]): number | null => {
87  const priced = rows.filter(r => r.usd !== null)
88  return priced.length === 0 ? null : sum(priced, r => r.usd ?? 0)
89}
90
91/** The one line pinned under the prompt. Parts that have no figure are left out. */
92export function statusText(
93  rows: readonly Row[],
94  sessionUsd: number | null,
95): string {
96  const last = rows.at(-1)
97  const parts: string[] = []
98
99  if (sessionUsd !== null) parts.push(`${fmtUsd(sessionUsd)} session`)
100
101  if (!last) {
102    parts.push('no turns yet - /ledger')
103    return parts.join(' · ')
104  }
105
106  if (last.usd !== null) parts.push(`last turn ${fmtUsd(last.usd)}`)
107
108  // "in" here is everything the answer was read over - fresh input, cache
109  // reads and cache writes - which is the figure the cache share is of.
110  const readOver = last.inTok + last.cacheRead + last.cacheWrite
111  parts.push(`${fmtTokens(readOver)} in / ${fmtTokens(last.outTok)} out`)
112
113  const hit = hitOf(last.inTok, last.cacheRead, last.cacheWrite)
114  if (hit !== null) parts.push(`cache ${hit}%`)
115
116  const line = parts.join(' · ')
117  return line.length <= 78 ? line : `${line.slice(0, 77)}…`
118}
119
120type ColDef = {
121  key: string
122  head: string
123  /** Higher is kept longer when the pane is too narrow for every column. */
124  pri: number
125  left?: true
126  cell: (r: Row) => string
127  total: (rows: readonly Row[]) => string
128}
129
130const COLS: readonly ColDef[] = [
131  { key: 'n', head: '#', pri: 3, cell: r => `${r.n}${r.sub ? '*' : ''}`, total: () => 'all' },
132  { key: 'model', head: 'model', pri: 4, left: true, cell: r => r.model, total: () => '' },
133  { key: 'in', head: 'in', pri: 9, cell: r => fmtTokens(r.inTok), total: rs => fmtTokens(sum(rs, r => r.inTok)) },
134  { key: 'out', head: 'out', pri: 8, cell: r => fmtTokens(r.outTok), total: rs => fmtTokens(sum(rs, r => r.outTok)) },
135  { key: 'cread', head: 'cache r', pri: 6, cell: r => fmtTokens(r.cacheRead), total: rs => fmtTokens(sum(rs, r => r.cacheRead)) },
136  { key: 'cwrite', head: 'cache w', pri: 2, cell: r => fmtTokens(r.cacheWrite), total: rs => fmtTokens(sum(rs, r => r.cacheWrite)) },
137  { key: 'hit', head: 'hit', pri: 5, cell: r => pct(hitOf(r.inTok, r.cacheRead, r.cacheWrite)), total: rs => pct(hitOf(sum(rs, r => r.inTok), sum(rs, r => r.cacheRead), sum(rs, r => r.cacheWrite))) },
138  { key: 'time', head: 'time', pri: 1, cell: r => fmtMs(r.ms), total: rs => fmtMs(sum(rs, r => r.ms)) },
139  { key: 'cost', head: 'cost', pri: 10, cell: r => fmtUsd(r.usd), total: rs => fmtUsd(sumUsd(rs)) },
140]
141
142const pad = (text: string, width: number, left: boolean): string =>
143  left ? text.padEnd(width) : text.padStart(width)
144
145export type Table = {
146  head: string
147  body: string[]
148  totals: string
149}
150
151/**
152 * Lays the rows out as fixed-width columns inside `columns` cells, newest
153 * first, dropping the least important column until the widest line fits.
154 *
155 * @param rows every turn kept, oldest first
156 * @param shown the newest turns to draw
157 * @param columns the pane body's width
158 */
159export function table(
160  rows: readonly Row[],
161  shown: readonly Row[],
162  columns: number,
163): Table {
164  const sized = COLS.map(c => {
165    const cells = shown.map(c.cell)
166    const tot = c.total(rows)
167    const w = Math.max(c.head.length, tot.length, ...cells.map(s => s.length), 1)
168    return { def: c, w, cells, tot }
169  })
170
171  const kept = new Set(sized.map(c => c.def.key))
172  const widthOf = () =>
173    sized
174      .filter(c => kept.has(c.def.key))
175      .reduce((total, c, i) => total + c.w + (i === 0 ? 0 : 1), 0)
176
177  for (const c of [...sized].sort((a, b) => a.def.pri - b.def.pri)) {
178    if (kept.size <= 1 || widthOf() <= columns) break
179    kept.delete(c.def.key)
180  }
181
182  const drawn = sized.filter(c => kept.has(c.def.key))
183
184  const line = (of: (c: (typeof drawn)[number], i: number) => string) =>
185    drawn.map((c, i) => pad(of(c, i), c.w, c.def.left === true)).join(' ')
186
187  return {
188    head: line(c => c.def.head),
189    body: shown.map((_, row) => line(c => c.cells[row] ?? '')),
190    totals: line((c, i) => (c.tot === '' && i === 0 ? 'all' : c.tot)),
191  }
192}
193