Prints a shareable receipt of your Claude Code session: tokens, cost, tools, files, and a few silly stats.

Claude Code mods by @sreshtalluri.

/plugin marketplace add sreshtalluri/claude-mods
/plugin install session-receipt@claude-mods
/plugin install daily-grind@claude-mods
/plugin install next-asks@claude-mods
Mods use Claude Code's function-hooks plugin API, which is early access, so you need a current Claude Code.
| Mod | What it does |
|---|---|
| session-receipt | /receipt prints a shareable receipt of your session. /receipt week totals your week. Copy image gives you a square PNG to post. |
| daily-grind | /grind opens a hub of daily puzzles to play while Claude works. Developer: Git Golf (reshape a commit graph under par), Heisenbug (find the buggy line), Install Order (dependency logic). Everyone: Ladder, Pixel Logic, Codebreaker. A Classics tab holds Lexer, Buckets and Daemons. Streaks, and /grind share for a shareable result. |
| next-asks | After each turn, a band above the prompt suggests up to three things worth asking next that you might not think of: a missed edge case, an unrun check, a related file. Press its number to put it in the prompt. One small Haiku call per turn. |

| Command | |
|---|---|
/receipt | This session: model, time, turns, tools, files, tokens, cache hit, cost, and how many times Claude said you were absolutely right. |
/receipt week | The last seven days: sessions, busiest day, top project, usual model, total. |
/receipt last | The previous session's receipt. |
~/.claude/receipts/ when it ends. Nothing leaves your machine.plugins/<name>/ (with .claude-plugin/plugin.json)..claude-plugin/marketplace.json.claude plugin validate plugins/<name>, claude plugin test plugins/<name> and claude plugin validate .The demo clip is a HyperFrames composition in media/receipt-demo: bun build.ts && npm run render.
MIT
hooks/register.tsx 260 lines1import { atom, read, update } from 'claude-code'
2import type { ButtonProps, EngineInterface, Register } from 'claude-code'
3
4import type { ReceiptLifetime, ReceiptStats } from '../types'
5import {
6 countAbsolutelyRight,
7 countApologies,
8 emptyStats,
9 DAY,
10 formatReceipt,
11 formatWeek,
12 isCredit,
13 isRule,
14 isShipCommand,
15 isTotal,
16 receiptFileName,
17 receiptSvg,
18 slipLines,
19 summarize,
20} from './format'
21import { clipboardCommand, drawCommands } from './image'
22import type { Platform } from './image'
23import type { ReceiptInput, SessionSummary } from './format'
24
25const stats = atom({ plugin: 'session-receipt', key: 'stats' } as const, emptyStats())
26const LIFETIME = 'lifetime'
27/** The last saved receipt: `{ lines, path, seen }`, for `/receipt last` and the next start's toast. */
28const LAST = 'last'
29type LastReceipt = { lines: string[]; path: string; seen: boolean }
30/** Finished sessions' summaries, a month deep, for `/receipt week`. */
31const HISTORY = 'history'
32const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
33
34/** Never let bookkeeping break the session: a failed write just loses a stat. */
35const tally = async ($: EngineInterface, fn: (s: ReceiptStats) => ReceiptStats) => {
36 try {
37 await update($, stats, fn)
38 } catch {
39 // stats are best effort
40 }
41}
42
43const readLifetime = async ($: EngineInterface): Promise<ReceiptLifetime> => {
44 const raw = (await $.store.get(LIFETIME)) as Partial<ReceiptLifetime> | undefined
45 return { sessions: Number(raw?.sessions ?? 0), usd: Number(raw?.usd ?? 0) }
46}
47
48const snapshot = async ($: EngineInterface): Promise<ReceiptInput> => {
49 const usage = await $.session.usage()
50 const lifetime = await readLifetime($)
51 return {
52 stats: await read($, stats),
53 usd: usage.cost?.usd ?? null,
54 startedAt: usage.startedAt,
55 now: await $.clock.now(),
56 cwd: await $.session.cwd(),
57 sessionId: await $.session.id(),
58 number: lifetime.sessions + 1,
59 model: await $.session.model().catch(() => null),
60 }
61}
62
63const isEmpty = (s: ReceiptStats) => s.turns === 0 && s.toolCalls === 0
64
65const readHistory = async ($: EngineInterface) =>
66 ((await $.store.get(HISTORY)) as SessionSummary[] | undefined) ?? []
67
68const receiptsDir = async ($: EngineInterface) =>
69 `${(await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~'}/.claude/receipts`
70
71/** The slip as a square PNG on the clipboard; where nothing can draw it, the SVG's path. */
72const copyImage = async ($: EngineInterface, lines: string[]) => {
73 try {
74 const dir = `${await receiptsDir($)}/images`
75 const svg = `${dir}/receipt.svg`
76 const png = `${svg}.png`
77 await $.fs.write(svg, receiptSvg(lines, { backdrop: true }))
78
79 const os: Platform =
80 (await $.env.get('OS')) === 'Windows_NT' ? 'windows'
81 : (await $.process.run(['uname']).then(r => r.stdout.trim(), () => '')) === 'Darwin' ? 'mac'
82 : 'linux'
83 const ok = (argv: string[]) => $.process.run(argv, { timeoutMs: 20_000 }).then(r => r.exitCode === 0, () => false)
84
85 let drawn = false
86 const win = {
87 programFiles: (await $.env.get('ProgramFiles')) ?? 'C:\\Program Files',
88 programFilesX86: (await $.env.get('ProgramFiles(x86)')) ?? 'C:\\Program Files (x86)',
89 localAppData: (await $.env.get('LOCALAPPDATA')) ?? '',
90 }
91 for (const argv of drawCommands(os, svg, png, dir, win)) if ((drawn = await ok(argv))) break
92
93 if (drawn && (await ok(clipboardCommand(os, png)))) $.ui.toast('🧾 Receipt image copied')
94 else $.ui.toast(`Receipt image saved: ${drawn ? png : svg}`, { timeoutMs: 8000 })
95 } catch {
96 $.ui.toast('Could not make the receipt image')
97 }
98}
99
100export const register: Register = on => {
101 // ponytail: module-level, so a reload can toast once more; fine for a nudge.
102 let shipToasted = false
103
104 on('session.start', async ($, e, next) => {
105 await $.command.register({
106 name: 'receipt',
107 description: 'Print a receipt for this session (or: last, week)',
108 argumentHint: '[last|week]',
109 })
110 const last = (await $.store.get(LAST)) as LastReceipt | undefined
111 if (last && !last.seen) {
112 const total = last.lines.find(isTotal)?.replace(/^TOTAL[ .]*/, '').trim()
113 $.ui.toast(`🧾 Last session's receipt${total ? ` (${total})` : ''}: /receipt last`, { timeoutMs: 8000 })
114 await $.store.set(LAST, { ...last, seen: true })
115 }
116 return next(e)
117 })
118
119 on('tool.call', async ($, e, next) => {
120 const startedAt = await $.clock.now()
121 const ran = await next(e)
122 const ms = (await $.clock.now()) - startedAt
123 const tool = String(e.tool)
124 const path = (e as { file_path?: unknown }).file_path
125
126 await tally($, s => ({
127 ...s,
128 toolCalls: s.toolCalls + 1,
129 toolCounts: { ...s.toolCounts, [tool]: (s.toolCounts[tool] ?? 0) + 1 },
130 failedCalls: s.failedCalls + (ran.isError === true ? 1 : 0),
131 deniedCalls: s.deniedCalls + (ran.deny !== undefined ? 1 : 0),
132 editCounts:
133 EDIT_TOOLS.has(tool) && typeof path === 'string' && ran.deny === undefined && ran.isError !== true
134 ? { ...s.editCounts, [path]: (s.editCounts[path] ?? 0) + 1 }
135 : s.editCounts,
136 longest: !s.longest || ms > s.longest.ms ? { tool, ms } : s.longest,
137 }))
138
139 if (!shipToasted && tool === 'Bash' && !ran.isError && ran.deny === undefined) {
140 const command = (e as { command?: unknown }).command
141 if (typeof command === 'string' && isShipCommand(command)) {
142 shipToasted = true
143 $.ui.toast('🧾 Shipped! /receipt for the tab')
144 }
145 }
146
147 return ran
148 })
149
150 on('turn.complete', async ($, e, next) => {
151 const isMain = e.agentId === undefined
152 const u = e.usage
153
154 await tally($, s => ({
155 ...s,
156 turns: s.turns + (isMain ? 1 : 0),
157 interrupts: s.interrupts + (isMain && e.isAborted ? 1 : 0),
158 absolutelyRight: s.absolutelyRight + (isMain ? countAbsolutelyRight(e.answer) : 0),
159 apologies: s.apologies + (isMain ? countApologies(e.answer) : 0),
160 tokensIn: s.tokensIn + (u?.input_tokens ?? 0),
161 tokensOut: s.tokensOut + (u?.output_tokens ?? 0),
162 cacheRead: s.cacheRead + (u?.cache_read_input_tokens ?? 0),
163 cacheWrite: s.cacheWrite + (u?.cache_creation_input_tokens ?? 0),
164 }))
165
166 return next(e)
167 })
168
169 on('command.run', { command: 'receipt' }, async ($, e) => {
170 if (e.args.trim() === 'last') {
171 const last = (await $.store.get(LAST)) as LastReceipt | undefined
172 if (!last) return { text: 'No saved receipt yet: one is saved when a session ends.' }
173 return { text: '```\n' + last.lines.join('\n') + '\n```\n' + last.path }
174 }
175 const now = await $.clock.now()
176 if (e.args.trim() === 'week') {
177 const current = await snapshot($)
178 const sessions = await readHistory($)
179 const lines = formatWeek(isEmpty(current.stats) ? sessions : [...sessions, summarize(current.stats, current)], now)
180 return { text: '```\n' + lines.join('\n') + '\n```' }
181 }
182 const lines = formatReceipt(await snapshot($))
183 return { text: '```\n' + lines.join('\n') + '\n```' }
184 })
185
186 // Draw the receipt as a bordered slip instead of a plain code block.
187 on('ui.render', { component: 'CommandOutput', props: { command: 'receipt' } }, async ($, e, next) => {
188 if (e.props.isErrored) return next(e)
189 const lines = slipLines(e.props.text)
190 if (lines.length < 5) return next(e)
191 const plain = lines.join('\n')
192
193 const copy: ButtonProps['onPress'] = press => {
194 void $.ui.copy({ text: plain, surface: press.surface })
195 $.ui.toast('Receipt copied')
196 }
197
198 const image: ButtonProps['onPress'] = () => void copyImage($, lines)
199
200 if (e.surface !== 'terminal') {
201 // Proportional-font surfaces: draw a paper slip instead of padded text.
202 const { Box, Svg, Button } = $.ui.resolve(e)
203 return (
204 <Box flexDirection="column" alignItems="flex-start" gap={1}>
205 <Svg key="slip" source={receiptSvg(lines)} alt={plain} />
206 <Box key="actions" flexDirection="row" gap={1}>
207 <Button key="copy" label="Copy text" onPress={copy} />
208 <Button key="image" label="Copy image" onPress={image} />
209 </Box>
210 </Box>
211 )
212 }
213
214 const { Box, Text, Button } = $.ui.resolve(e)
215 return (
216 <Box flexDirection="column" alignItems="flex-start">
217 <Box key="slip" flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
218 {lines.map((line, i) =>
219 i === 0 ? (
220 <Text bold>{line}</Text>
221 ) : isTotal(line) ? (
222 <Text bold color="green">
223 {line}
224 </Text>
225 ) : isRule(line) || isCredit(line) ? (
226 <Text dimColor>{line}</Text>
227 ) : (
228 <Text>{line}</Text>
229 ),
230 )}
231 </Box>
232 <Box key="actions" flexDirection="row" gap={1}>
233 <Button key="copy" label="Copy text" onPress={copy} />
234 <Button key="image" label="Copy image" onPress={image} />
235 </Box>
236 </Box>
237 )
238 })
239
240 on('session.end', async ($, e, next) => {
241 try {
242 const r = await snapshot($)
243 // An opened-and-closed session gets no receipt and no number.
244 if (!isEmpty(r.stats)) {
245 const lines = formatReceipt(r)
246 const path = `${await receiptsDir($)}/${receiptFileName(r.now, r.cwd, r.number)}`
247 const history = (await readHistory($)).filter(x => r.now - x.at < 35 * DAY)
248 await $.store.set(LIFETIME, { sessions: r.number, usd: (await readLifetime($)).usd + (r.usd ?? 0) })
249 await $.store.set(HISTORY, [...history, summarize(r.stats, r)])
250 await $.store.set(LAST, { lines, path, seen: false } satisfies LastReceipt)
251 await $.fs.write(path, lines.join('\n') + '\n')
252 }
253 if (e.reason === 'clear') await update($, stats, () => emptyStats())
254 } catch {
255 // best effort inside session.end's short budget
256 }
257 return next(e)
258 })
259}
260hooks/format.ts 368 lines1import type { ReceiptStats } from '../types'
2
3export const WIDTH = 34
4export const RULE = '-'.repeat(WIDTH)
5export const DOUBLE_RULE = '='.repeat(WIDTH)
6
7export const emptyStats = (): ReceiptStats => ({
8 turns: 0,
9 interrupts: 0,
10 toolCalls: 0,
11 failedCalls: 0,
12 deniedCalls: 0,
13 toolCounts: {},
14 editCounts: {},
15 longest: null,
16 tokensIn: 0,
17 tokensOut: 0,
18 cacheRead: 0,
19 cacheWrite: 0,
20 absolutelyRight: 0,
21 apologies: 0,
22})
23
24export type ReceiptInput = {
25 stats: ReceiptStats
26 usd: number | null
27 startedAt: number
28 now: number
29 cwd: string
30 sessionId: string
31 number: number
32 model: string | null
33}
34
35/** Where to get one: the receipt's small print, so a shared screenshot says it. */
36export const CREDIT = 'sreshtalluri/claude-mods'
37
38/** `claude-opus-5-5[1m]` reads as `OPUS 5.5` on a receipt; anything else, as given. */
39export const prettyModel = (model: string): string => {
40 const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?!\d)/i.exec(model)
41 return (m ? `${m[1]} ${m[2]}${m[3] ? '.' + m[3] : ''}` : model.replace(/\[.*\]$/, '')).toUpperCase()
42}
43
44const ABSOLUTELY_RIGHT = /you'?re (absolutely|completely|totally) right/gi
45const APOLOGY = /\b(i apologi[sz]e|my apologies|sorry about that|i'm sorry)\b/gi
46
47export const countMatches = (text: string, re: RegExp): number =>
48 (text.match(re) ?? []).length
49
50export const countAbsolutelyRight = (text: string) => countMatches(text, ABSOLUTELY_RIGHT)
51export const countApologies = (text: string) => countMatches(text, APOLOGY)
52
53/** Left label, right value, dots in between, exactly WIDTH wide. */
54export const row = (label: string, rawValue: string): string => {
55 const max = WIDTH - 10
56 const value = rawValue.length > max ? '~' + rawValue.slice(rawValue.length - max + 1) : rawValue
57 const room = WIDTH - value.length - 1
58 const left = label.length > room - 1 ? label.slice(0, room - 2) + '~' : label
59 const dots = Math.max(1, WIDTH - left.length - value.length)
60 return left + (dots > 2 ? ' ' + '.'.repeat(dots - 2) + ' ' : ' '.repeat(dots)) + value
61}
62
63export const center = (text: string): string => {
64 const t = text.length > WIDTH ? text.slice(0, WIDTH) : text
65 const pad = Math.floor((WIDTH - t.length) / 2)
66 return ' '.repeat(pad) + t + ' '.repeat(WIDTH - t.length - pad)
67}
68
69export const compact = (n: number): string => {
70 if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + 'M'
71 if (n >= 1_000) return (n / 1_000).toFixed(n >= 10_000 ? 0 : 1) + 'k'
72 return String(n)
73}
74
75export const duration = (ms: number): string => {
76 const s = Math.max(0, Math.round(ms / 1000))
77 const h = Math.floor(s / 3600)
78 const m = Math.floor((s % 3600) / 60)
79 const sec = s % 60
80 if (h > 0) return `${h}h ${m}m`
81 if (m > 0) return `${m}m ${sec}s`
82 return `${sec}s`
83}
84
85const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
86const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
87
88export const stamp = (ms: number): string => {
89 const d = new Date(ms)
90 const h = d.getHours()
91 const hh = String(h % 12 === 0 ? 12 : h % 12)
92 const mm = String(d.getMinutes()).padStart(2, '0')
93 return `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${d.getDate()} ${d.getFullYear()} ${hh}:${mm} ${h < 12 ? 'AM' : 'PM'}`
94}
95
96/** mcp__github__create_issue reads as github.create_issue on a receipt. */
97export const shortTool = (tool: string): string => {
98 const parts = tool.split('__')
99 return parts[0] === 'mcp' && parts.length >= 3 ? `${parts[1]}.${parts.slice(2).join('__')}` : tool
100}
101
102const basename = (path: string): string => path.split(/[\\/]/).filter(Boolean).pop() ?? path
103
104/** A fake barcode that is stable per session id, for the screenshot. */
105export const barcode = (seed: string): string => {
106 let hash = 2166136261
107 for (let i = 0; i < seed.length; i++) {
108 hash ^= seed.charCodeAt(i)
109 hash = Math.imul(hash, 16777619) >>> 0
110 }
111 const glyphs = ['|', '|', '||', ' ', '|', '| ', '|||', ' ']
112 let out = ''
113 while (out.length < WIDTH - 4) {
114 hash = Math.imul(hash ^ (hash >>> 13), 1274126177) >>> 0
115 out += glyphs[hash % glyphs.length]
116 }
117 return center(out.slice(0, WIDTH - 4))
118}
119
120/** Footer, barcode and small print: how every slip ends. */
121const close = (push: (line: string) => void, thanks: string, seed: string) => {
122 push(center(thanks))
123 push(barcode(seed))
124 push(center(CREDIT))
125}
126
127const footer = (s: ReceiptStats): string => {
128 if (s.absolutelyRight >= 3) return 'YOU WERE ABSOLUTELY RIGHT'
129 if (s.failedCalls >= 10) return 'IT WORKED ON MY MACHINE'
130 if (s.toolCalls === 0) return 'JUST HERE TO TALK'
131 if (s.interrupts >= 3) return 'PATIENCE IS A VIRTUE'
132 return 'THANK YOU, COME AGAIN'
133}
134
135/** The receipt as plain lines, every one exactly WIDTH wide. */
136export const formatReceipt = (r: ReceiptInput): string[] => {
137 const s = r.stats
138 const lines: string[] = []
139 const push = (line: string) => lines.push(line.padEnd(WIDTH))
140
141 push(center('* SESSION RECEIPT *'))
142 push(center('CLAUDE CODE'))
143 push(center(`RECEIPT #${String(r.number).padStart(4, '0')}`))
144 push(center(stamp(r.now)))
145 push(center(basename(r.cwd) || '~'))
146 if (r.model) push(center(`SERVED BY ${prettyModel(r.model)}`))
147 push(RULE)
148
149 const tools = Object.entries(s.toolCounts).sort((a, b) => b[1] - a[1])
150 if (tools.length === 0) {
151 push(center('(no tools were harmed)'))
152 } else {
153 for (const [tool, count] of tools.slice(0, 6)) push(row(shortTool(tool), `x${count}`))
154 if (tools.length > 6) {
155 const rest = tools.slice(6).reduce((n, [, c]) => n + c, 0)
156 push(row(`+${tools.length - 6} MORE`, `x${rest}`))
157 }
158 }
159 push(RULE)
160
161 const files = Object.entries(s.editCounts).sort((a, b) => b[1] - a[1])
162 const totalIn = s.tokensIn + s.cacheRead + s.cacheWrite
163 const cacheHit = totalIn > 0 ? Math.round((s.cacheRead / totalIn) * 100) : 0
164
165 push(row('TIME', duration(r.now - r.startedAt)))
166 push(row('TURNS', String(s.turns)))
167 push(row('TOOL CALLS', String(s.toolCalls)))
168 push(row('FILES CHANGED', String(files.length)))
169 if (files[0]) push(row('MOST EDITED', `${basename(files[0][0])} x${files[0][1]}`))
170 if (s.longest) {
171 const wait = s.longest.ms < 10_000 ? `${(s.longest.ms / 1000).toFixed(1)}s` : duration(s.longest.ms)
172 push(row('LONGEST WAIT', `${shortTool(s.longest.tool)} ${wait}`))
173 }
174 push(row('FAILED CALLS', String(s.failedCalls)))
175 if (s.deniedCalls > 0) push(row('DENIED', String(s.deniedCalls)))
176 push(row('TOKENS IN', compact(totalIn)))
177 push(row('TOKENS OUT', compact(s.tokensOut)))
178 push(row('CACHE HIT', `${cacheHit}%`))
179 push(RULE)
180 push(row('"ABSOLUTELY RIGHT"', `x${s.absolutelyRight}`))
181 push(row('APOLOGIES', `x${s.apologies}`))
182 push(row('INTERRUPTIONS', `x${s.interrupts}`))
183 push(DOUBLE_RULE)
184 push(row('TOTAL', r.usd === null ? 'n/a' : `$${r.usd.toFixed(2)}`))
185 push(DOUBLE_RULE)
186 close(push, footer(s), `${r.sessionId}:${r.startedAt}:${r.number}`)
187
188 return lines
189}
190
191/** Lines the drawing styles differently, by exact content. */
192export const isRule = (line: string) => line === RULE || line === DOUBLE_RULE
193export const isTotal = (line: string) => line.startsWith('TOTAL')
194export const isCredit = (line: string) => line.trim() === CREDIT
195const isBarcode = (line: string) => line.includes('|') && /^[| ]+$/.test(line)
196
197/** The receipt's lines out of a CommandOutput row: what sits between the ``` fences. */
198export const slipLines = (text: string): string[] => {
199 const lines = text.split('\n')
200 const open = lines.findIndex(l => l.includes('```'))
201 const close = lines.findLastIndex(l => l.includes('```'))
202 return close > open && open >= 0 ? lines.slice(open + 1, close) : lines
203}
204
205const xml = (s: string) =>
206 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
207
208/**
209 * The receipt as a paper slip for surfaces that draw SVG (desktop, mobile).
210 * Rules and the barcode are drawn as shapes, so only the text depends on the
211 * font being monospace.
212 */
213export const receiptSvg = (lines: string[], { backdrop = false } = {}): string => {
214 const FS = 13
215 const CW = FS * 0.6 // monospace advance
216 const LH = 19
217 const PAD = 18
218 const TOOTH = 7
219 const w = Math.round(PAD * 2 + WIDTH * CW)
220 const bodyH = PAD * 2 + lines.length * LH
221 const h = bodyH + TOOTH * 2
222
223 // Torn edges: a zigzag along the top and bottom.
224 const teeth = Math.round(w / (TOOTH * 2))
225 const step = w / teeth
226 const f = (n: number) => +n.toFixed(1)
227 let edge = `M0 ${TOOTH}`
228 for (let i = 0; i < teeth; i++) edge += ` L${f((i + 0.5) * step)} 0 L${f((i + 1) * step)} ${TOOTH}`
229 edge += ` L${w} ${h - TOOTH}`
230 for (let i = teeth; i > 0; i--) edge += ` L${f((i - 0.5) * step)} ${h} L${f((i - 1) * step)} ${h - TOOTH}`
231 edge += ' Z'
232
233 const body = lines.map((line, i) => {
234 const y = TOOTH + PAD + i * LH
235 const mid = y + LH / 2
236 if (line === RULE) return `<line x1="${PAD}" x2="${w - PAD}" y1="${mid}" y2="${mid}" stroke="#9a968c" stroke-dasharray="4 3"/>`
237 if (line === DOUBLE_RULE)
238 return `<line x1="${PAD}" x2="${w - PAD}" y1="${mid - 2}" y2="${mid - 2}" stroke="#2b2a27"/><line x1="${PAD}" x2="${w - PAD}" y1="${mid + 2}" y2="${mid + 2}" stroke="#2b2a27"/>`
239 if (isBarcode(line))
240 return [...line]
241 .map((c, j) => (c === '|' ? `<rect x="${(PAD + j * CW).toFixed(1)}" y="${y - 2}" width="${(CW * 0.55).toFixed(1)}" height="${LH + 1}" fill="#2b2a27"/>` : ''))
242 .join('')
243 const style =
244 i === 0 ? ' font-weight="700"'
245 : isTotal(line) ? ' font-weight="700" fill="#1f7a3a"'
246 : isCredit(line) ? ` fill="#6b675e" font-size="${FS - 2}"`
247 : ''
248 // Centered lines anchor on the middle; rows are stretched to the slip's
249 // width, so the dot leaders line up whatever monospace the surface has.
250 const text = line.trimEnd()
251 return line.startsWith(' ')
252 ? `<text x="${w / 2}" y="${y + FS}" text-anchor="middle"${style}>${xml(text.trim())}</text>`
253 : `<text x="${PAD}" y="${y + FS}" textLength="${(text.length * CW).toFixed(1)}" lengthAdjust="spacingAndGlyphs"${style}>${xml(text)}</text>`
254 })
255
256 const slip =
257 `<path d="${edge}" fill="#000" opacity="0.18" transform="translate(0 3)"/>` +
258 `<path d="${edge}" fill="#fbf9f3"/>` +
259 `<g font-family="ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New', monospace" font-size="${FS}" fill="#2b2a27" xml:space="preserve" style="white-space:pre">` +
260 body.join('') +
261 '</g>'
262 // The shareable image: the slip centred on a dark square, the shape feeds crop to.
263 const side = Math.max(w, h) + 80
264 return backdrop
265 ? `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 ${side} ${side}">` +
266 `<rect width="${side}" height="${side}" fill="#1f1e1c"/>` +
267 `<g transform="translate(${Math.round((side - w) / 2)} ${Math.round((side - h) / 2)})">${slip}</g></svg>`
268 : `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h + 12}" viewBox="0 0 ${w} ${h + 12}">${slip}</svg>`
269}
270
271/** `2026-10-03-claude-mods-0042.txt`: sorts by date, says where and which. */
272export const receiptFileName = (now: number, cwd: string, number: number): string => {
273 const d = new Date(now)
274 const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
275 const project = (basename(cwd) || 'home').replace(/[^\w.-]+/g, '_')
276 return `${date}-${project}-${String(number).padStart(4, '0')}.txt`
277}
278
279/** A Bash command that ships something: a commit or a new PR. */
280export const isShipCommand = (command: string) => /\bgit\s+commit\b|\bgh\s+pr\s+create\b/.test(command)
281
282/** One finished session, kept for `/receipt week`. */
283export type SessionSummary = {
284 at: number
285 ms: number
286 usd: number
287 turns: number
288 toolCalls: number
289 files: number
290 tokensIn: number
291 tokensOut: number
292 absolutelyRight: number
293 apologies: number
294 project: string
295 model: string | null
296 toolCounts: Record<string, number>
297}
298
299export const DAY = 86_400_000
300
301export const summarize = (s: ReceiptStats, r: Omit<ReceiptInput, 'stats' | 'sessionId' | 'number'>): SessionSummary => ({
302 at: r.now,
303 ms: r.now - r.startedAt,
304 usd: r.usd ?? 0,
305 turns: s.turns,
306 toolCalls: s.toolCalls,
307 files: Object.keys(s.editCounts).length,
308 tokensIn: s.tokensIn + s.cacheRead + s.cacheWrite,
309 tokensOut: s.tokensOut,
310 absolutelyRight: s.absolutelyRight,
311 apologies: s.apologies,
312 project: basename(r.cwd) || '~',
313 model: r.model,
314 toolCounts: s.toolCounts,
315})
316
317const top = (counts: Record<string, number>) => Object.entries(counts).sort((a, b) => b[1] - a[1])
318
319/** The last seven days as one slip, every line exactly WIDTH wide. */
320export const formatWeek = (sessions: SessionSummary[], now: number): string[] => {
321 const week = sessions.filter(x => now - x.at < 7 * DAY)
322 const lines: string[] = []
323 const push = (line: string) => lines.push(line.padEnd(WIDTH))
324 const sum = (f: (x: SessionSummary) => number) => week.reduce((n, x) => n + f(x), 0)
325 const tally = (key: (x: SessionSummary) => string | null, weight: (x: SessionSummary) => number = () => 1) => {
326 const out: Record<string, number> = {}
327 for (const x of week) {
328 const k = key(x)
329 if (k) out[k] = (out[k] ?? 0) + weight(x)
330 }
331 return out
332 }
333 const tools: Record<string, number> = {}
334 for (const x of week) for (const [t, c] of Object.entries(x.toolCounts)) tools[t] = (tools[t] ?? 0) + c
335
336 push(center('* WEEKLY RECEIPT *'))
337 push(center('CLAUDE CODE'))
338 push(center(`${stamp(now - 6 * DAY).slice(4, 10).trim()} - ${stamp(now).slice(4, 10).trim()}`))
339 push(RULE)
340
341 const ranked = top(tools)
342 if (ranked.length === 0) push(center('(no tools were harmed)'))
343 for (const [tool, count] of ranked.slice(0, 5)) push(row(shortTool(tool), `x${count}`))
344 push(RULE)
345
346 const busiest = top(tally(x => DAYS[new Date(x.at).getDay()] ?? null, x => x.ms))[0]
347 const project = top(tally(x => x.project, x => x.ms))[0]
348 const model = top(tally(x => (x.model ? prettyModel(x.model) : null), x => x.ms))[0]
349 push(row('SESSIONS', String(week.length)))
350 push(row('TIME', duration(sum(x => x.ms))))
351 push(row('TURNS', String(sum(x => x.turns))))
352 push(row('TOOL CALLS', String(sum(x => x.toolCalls))))
353 push(row('FILES CHANGED', String(sum(x => x.files))))
354 if (busiest) push(row('BUSIEST DAY', busiest[0]))
355 if (project) push(row('TOP PROJECT', project[0]))
356 if (model) push(row('USUAL ORDER', model[0]))
357 push(row('TOKENS IN', compact(sum(x => x.tokensIn))))
358 push(row('TOKENS OUT', compact(sum(x => x.tokensOut))))
359 push(RULE)
360 push(row('"ABSOLUTELY RIGHT"', `x${sum(x => x.absolutelyRight)}`))
361 push(row('APOLOGIES', `x${sum(x => x.apologies)}`))
362 push(DOUBLE_RULE)
363 push(row('TOTAL', `$${sum(x => x.usd).toFixed(2)}`))
364 push(DOUBLE_RULE)
365 close(push, week.length >= 20 ? 'SEE YOU TOMORROW' : 'THANK YOU, COME AGAIN', `week:${week.map(x => x.at).join(',')}`)
366 return lines
367}
368hooks/image.ts 50 lines1/**
2 * The commands that turn a receipt SVG into a PNG and put it on the clipboard,
3 * per OS, using what the machine already has: Quick Look, a Chromium browser
4 * (Edge ships with Windows) or rsvg-convert to draw it, the OS's own clipboard
5 * command to copy it. Pure: register.tsx runs them, first success wins.
6 */
7export type Platform = 'mac' | 'windows' | 'linux'
8
9/** Windows' folders, from its environment; ignored elsewhere. */
10export type WindowsDirs = { programFiles: string; programFilesX86: string; localAppData: string }
11
12export const SIZE = 1200
13
14export const drawCommands = (os: Platform, svg: string, png: string, dir: string, win?: WindowsDirs): string[][] => {
15 const url = 'file:///' + svg.replace(/\\/g, '/').replace(/^\/+/, '')
16 const shot = (browser: string) => [browser, '--headless=new', '--disable-gpu', '--hide-scrollbars',
17 `--window-size=${SIZE},${SIZE}`, '--force-device-scale-factor=1', `--screenshot=${png}`, url]
18 if (os === 'windows') {
19 const w = win ?? { programFiles: 'C:\\Program Files', programFilesX86: 'C:\\Program Files (x86)', localAppData: '' }
20 return [
21 `${w.programFilesX86}\\Microsoft\\Edge\\Application\\msedge.exe`,
22 `${w.programFiles}\\Microsoft\\Edge\\Application\\msedge.exe`,
23 `${w.programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
24 `${w.localAppData}\\Google\\Chrome\\Application\\chrome.exe`,
25 ].map(shot)
26 }
27 if (os === 'mac') {
28 return [
29 // Quick Look names its output <svg>.png, which is `png`.
30 ['qlmanage', '-t', '-s', String(SIZE), '-o', dir, svg],
31 shot('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
32 shot('/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'),
33 ]
34 }
35 return [
36 ...['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge'].map(shot),
37 ['rsvg-convert', '-w', String(SIZE), '-h', String(SIZE), '-o', png, svg],
38 ]
39}
40
41export const clipboardCommand = (os: Platform, png: string): string[] =>
42 os === 'windows'
43 ? ['powershell', '-NoProfile', '-STA', '-Command',
44 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
45 `[System.Windows.Forms.Clipboard]::SetImage([System.Drawing.Image]::FromFile('${png.replace(/'/g, "''")}'))`]
46 : os === 'mac'
47 ? ['osascript', '-e', `set the clipboard to (read (POSIX file "${png.replace(/["\\]/g, '\\$&')}") as «class PNGf»)`]
48 : // Both fork a server for the clipboard: send its output away so the call returns.
49 ['sh', '-c', 'wl-copy --type image/png < "$1" >/dev/null 2>&1 || xclip -selection clipboard -t image/png -i "$1" >/dev/null 2>&1', 'sh', png]
50types/index.d.ts 25 lines1export type ReceiptStats = {
2 turns: number
3 interrupts: number
4 toolCalls: number
5 failedCalls: number
6 deniedCalls: number
7 toolCounts: Record<string, number>
8 editCounts: Record<string, number>
9 longest: { tool: string; ms: number } | null
10 tokensIn: number
11 tokensOut: number
12 cacheRead: number
13 cacheWrite: number
14 absolutelyRight: number
15 apologies: number
16}
17
18export type ReceiptLifetime = { sessions: number; usd: number }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'session-receipt': { stats: ReceiptStats }
23 }
24}
25