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…

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

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.
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.
hooks/register.tsx 299 lines1/* @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}
299hooks/format.ts 193 lines1// 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