SLOPSHOPPER

Session Recap

A recap of the conversation you just cleared, as a card to share: time worked, turns, tool calls, files and lines changed, test runs and cost.

newpaneguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-recap
│ ┃ Session Recap ✕ › fix the failing auth test and add an audit log call │ ┃ app So far · 9 Oct 2025 │ ┃ 42s 1 turn ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Tool calls 9 ⏺ Update(src/auth.ts) │ ┃ Files edited 3 ⎿ Added 2 lines, removed 1 line │ ┃ Lines +14 −1 ⏺ Bash(bun test) │ ┃ Most used Bash ⎿ 3 pass, 1 fail │ ┃ Test runs 1 │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-recap │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session Recap
app So far · 9 Oct 2025 42s 1 turn Tool calls 9 Files edited 3 Lines +14 −1 Most used Bash Test runs 1
README

Session Recap

When you /clear a conversation, a pane opens with a recap of it as a card you can share: the time Claude worked, turns, tool calls, files and lines changed, test runs and cost.

A Session Recap card

The card is drawn for the Desktop app. In the terminal the pane lists the same figures as text.

What the card says

  • Time worked: the time Claude spent working across your turns, not the time the session stayed open. Under it, the turns and the model.
  • Tool calls: every tool call of the conversation, its subagents' included.
  • Files edited and Lines: the files Claude edited or wrote, and the lines it added and removed. The lines are counted from what Claude wrote, so they are an estimate, not a diff.
  • Most used: the tool called most often.
  • Test runs: the commands that ran a test runner (vitest, jest, pytest, go test, cargo test, bun test, npm test and the like).
  • Cost: what this conversation cost, with its tokens. On a subscription this is the API-price equivalent, not a bill.
  • Subagents, Skills, Connectors: how many were used.

A tile with nothing to say gives its place to the next one, so a card never shows a zero: a research conversation shows subagents and connectors where a build shows files and lines. The card names the project by its folder and never shows a file name or a path.

Using it

  • /clear opens the recap of the conversation you just cleared, once it had at least one turn. Esc closes it.
  • /session-recap shows the conversation so far without clearing it, or the last recap again when the new conversation has not started.
  • Copy image puts the card on the clipboard as a 2400 × 1350 PNG. Save image writes it to ~/Pictures/Session Recaps and shows it in the Finder. Both need macOS: the image is drawn by Quick Look, so it is set in the system typeface with nothing to install.

Install

claude plugin marketplace add endless-fr/claude-mods
claude plugin install session-recap@endless-claude-mods

Run /reload-plugins in a session that is already open. Requires Claude Code v2.1.287 or later in the terminal, v2.1.286 or later in the Desktop app.

What it reads and keeps

The mod makes no network requests, calls no model and sends nothing anywhere.

  • Reads: each tool call's name and, for an edit, its file path and the text it replaced and wrote; for a Bash call, its command, to tell a test run; each main-conversation request's token counts; each turn's length; the session's cost; the working directory's name.
  • Keeps, for the session only: the running counts and the last recap. Nothing is written to disk until you press a button.
  • Runs, only when you press Copy image or Save image: qlmanage and sips to draw the image, osascript to copy it, mkdir, cp and open to save and show it, rm to remove its working files from the temporary folder.
  • Hooks /clear, to open the pane once the command has run. It changes nothing about what /clear does.

To list this yourself before installing, run claude plugin validate mods/session-recap from a clone.

Develop

claude plugin validate mods/session-recap
claude plugin test mods/session-recap

The counting and the choice of tiles live in hooks/stats.ts, the drawing in hooks/card.ts and the image commands in hooks/export.ts, all three free of any engine call. hooks/register.tsx wires them to the session and runs the commands.

Credits

Inspired by OneWave AI's session-wrapped. Session Recap is written from scratch and shares no code with it.

Source 5 files
hooks/register.tsx 265 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Recap, Stats } from '../types'
5import { cardSvg } from './card'
6import { copyOf, drawingOf, savingOf } from './export'
7import { altOf, emptyStats, recapOf, tilesOf, withCall, withStep, withTurn } from './stats'
8import type { Call } from './stats'
9
10const PANE = 'session-recap'
11const TITLE = 'Session Recap'
12const NOTHING = 'Nothing to recap yet. The recap opens when you /clear a conversation.'
13
14const stats = atom({ plugin: 'session-recap', key: 'stats' } as const, null as Stats | null)
15const card = atom({ plugin: 'session-recap', key: 'card' } as const, null as Recap | null)
16const outcome = atom({ plugin: 'session-recap', key: 'outcome' } as const, '')
17
18// True from the moment a /clear froze a recap until the command that typed it has run.
19let hasCleared = false
20// The conversation the last /clear ended. A /clear starts the session's state
21// over, so what session.end wrote there is gone by the time the pane draws:
22// the module carries it across, into the new conversation's state.
23let lastCleared: { recap: Recap; cost: number | null } | null = null
24
25const costOf = async ($: EngineInterface) => (await $.session.usage()).cost?.usd ?? null
26
27/** The arguments of a tool call the counts read, whichever tool it was. */
28function callOf(e: { tool: string }): Call {
29  const input = e as Record<string, unknown>
30  const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
31
32  return {
33    tool: e.tool,
34    filePath: text('file_path') ?? text('notebook_path'),
35    oldText: text('old_string'),
36    newText: text('new_string') ?? text('content') ?? text('new_source'),
37    command: text('command'),
38    skill: text('skill'),
39  }
40}
41
42/** The running conversation as a recap, and what the session has cost by now; no recap before its first turn ends. */
43async function freeze($: EngineInterface, isSoFar: boolean): Promise<{ recap: Recap | null; cost: number | null }> {
44  const counted = await read($, stats)
45  const cost = await costOf($)
46  if (!counted || counted.turns === 0) return { recap: null, cost }
47
48  return { recap: recapOf(counted, { now: await $.clock.now(), cwd: await $.session.cwd(), cost, isSoFar }), cost }
49}
50
51async function show($: EngineInterface, recap: Recap) {
52  await update($, card, () => recap)
53  await update($, outcome, () => '')
54}
55
56/** Puts the last cleared conversation into the new one's state, and starts its counts from the cost so far. */
57async function carry($: EngineInterface) {
58  if (!lastCleared) return
59  const { recap, cost } = lastCleared
60  if ((await read($, card)) === null) await show($, recap)
61  if ((await read($, stats)) === null) await update($, stats, counted => counted ?? emptyStats(cost))
62}
63
64const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, holdToasts: true })
65
66/** Runs one command; true when it exited cleanly. Rejects when it cannot start. */
67const done = async ($: EngineInterface, argv: string[]) => (await $.process.run(argv, { timeoutMs: 20_000 })).exitCode === 0
68
69/** Runs the commands in order; true when every one exited cleanly, stopping at the first that did not. */
70async function allDone($: EngineInterface, list: string[][]) {
71  for (const argv of list) if (!(await done($, argv))) return false
72
73  return true
74}
75
76async function copy($: EngineInterface, png: string) {
77  return (await done($, copyOf(png))) ? 'Copied. Paste it anywhere.' : "Couldn't reach the clipboard. Nothing was copied."
78}
79
80async function save($: EngineInterface, recap: Recap, png: string) {
81  const home = await $.env.get('HOME')
82  if (!home) return "Couldn't find your home folder. Nothing was saved."
83  const saving = savingOf(recap, png, home)
84  if (!(await allDone($, saving.file))) return "Couldn't write to Pictures. Nothing was saved."
85  await done($, saving.reveal)
86
87  return `Saved to ${saving.where}.`
88}
89
90/** Makes the image of the card on show, copies or saves it, and resolves to what to tell the person. */
91async function image($: EngineInterface, recap: Recap, isSave: boolean): Promise<string> {
92  const nothing = isSave ? 'Nothing was saved.' : 'Nothing was copied.'
93  const drawing = drawingOf(recap, await $.env.get('TMPDIR'))
94  try {
95    await $.fs.write(drawing.svg, drawing.source)
96    try {
97      if (!(await allDone($, drawing.draw))) return `Couldn't make the image. ${nothing}`
98
99      return isSave ? await save($, recap, drawing.png) : await copy($, drawing.png)
100    } finally {
101      await $.process.run(drawing.clean)
102    }
103  } catch {
104    // A command that cannot start: these tools are macOS's own.
105    return `The image needs macOS. ${nothing}`
106  }
107}
108
109/** A press of Copy image or Save image: makes the image and says what came of it. */
110async function share($: EngineInterface, isSave: boolean) {
111  const recap = await read($, card)
112  if (!recap) return
113  await update($, outcome, () => '')
114  const said = await image($, recap, isSave)
115  await update($, outcome, () => said)
116}
117
118function desktopPane($: EngineInterface, { Box, Button, Svg, Text }: Elements['desktop'], recap: Recap, said: string): RenderElement {
119  return (
120    <Box flexDirection="column" gap={1}>
121      {/* An image, not `isInteractive`: it takes the pane's width and still
122          animates, where the frame stays 150 tall and the card shrinks into it. */}
123      <Svg source={cardSvg(recap)} alt={altOf(recap)} />
124      <Box gap={1} alignItems="center">
125        <Button key="copy" variant="primary" onPress={() => void share($, false)}>
126          Copy image
127        </Button>
128        <Button key="save" variant="secondary" onPress={() => void share($, true)}>
129          Save image
130        </Button>
131        {said ? (
132          <Box key="outcome">
133            <Text dimColor>{said}</Text>
134          </Box>
135        ) : null}
136      </Box>
137    </Box>
138  )
139}
140
141/** The same figures as rows of text, where no surface draws the card. */
142function listPane({ Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>, recap: Recap): RenderElement {
143  const { headline, accent, small } = tilesOf(recap)
144
145  return (
146    <Box flexDirection="column" paddingX={1}>
147      <Box gap={1}>
148        <Text bold>{headline.project}</Text>
149        <Text dimColor>{headline.date}</Text>
150      </Box>
151      <Box gap={1} marginBottom={1}>
152        <Text bold>{headline.time}</Text>
153        <Text dimColor>{headline.note}</Text>
154      </Box>
155      {[accent, ...small].map(tile => (
156        <Box gap={1}>
157          <Box width={13}>
158            <Text dimColor>{tile.label}</Text>
159          </Box>
160          <Text bold>{tile.removed ? `${tile.value} ${tile.removed}` : tile.value}</Text>
161          {tile.note ? <Text dimColor>{tile.note}</Text> : null}
162        </Box>
163      ))}
164    </Box>
165  )
166}
167
168export const register: Register = on => {
169  on('session.start', async ($, e, next) => {
170    await $.command.register({ name: 'session-recap', description: 'Show the recap of this conversation, or of the last one you cleared' })
171    // A reload of the mod starts this module over, not the conversation.
172    if ((await read($, stats)) === null) {
173      const cost = await costOf($)
174      await update($, stats, counted => counted ?? emptyStats(cost))
175    }
176
177    return next(e)
178  })
179
180  on('tool.call', async ($, e, next) => {
181    const ran = await next(e)
182    if (ran.deny === undefined) await update($, stats, counted => withCall(counted ?? emptyStats(null), callOf(e)))
183
184    return ran
185  })
186
187  // Each request of the main conversation: a subagent's tokens are its own.
188  on('turn.step', async function* ($, e, next) {
189    const result = yield* next(e)
190    const { usage } = result
191    if (e.agentId === undefined && usage) await update($, stats, counted => withStep(counted ?? emptyStats(null), usage))
192
193    return result
194  })
195
196  on('turn.complete', async ($, e, next) => {
197    const result = await next(e)
198    if (e.agentId === undefined) await update($, stats, counted => withTurn(counted ?? emptyStats(null), e.durationMs))
199
200    return result
201  })
202
203  on('session.end', async ($, e, next) => {
204    if (e.reason !== 'clear') return next(e)
205    // The process goes on with a new conversation, and no session.start says so.
206    const { recap, cost } = await freeze($, false)
207    await update($, stats, () => emptyStats(cost))
208    if (recap) {
209      lastCleared = { recap, cost }
210      await show($, recap)
211    }
212    const ended = await next(e)
213    if (recap) {
214      hasCleared = true
215      await carry($)
216      await open($).catch(() => undefined)
217    }
218
219    return ended
220  })
221
222  on('command.run', { command: 'clear' }, async ($, e, next) => {
223    hasCleared = false
224    const ran = await next(e)
225    // Opened from session.end the pane is unasked for, and waits undrawn on a
226    // narrow terminal. Here the person's own command is behind it.
227    if (hasCleared) {
228      await carry($)
229      await open($).catch(() => undefined)
230    }
231    hasCleared = false
232
233    return ran
234  })
235
236  on('command.run', { command: 'session-recap' }, async $ => {
237    const { recap } = await freeze($, true)
238    if (recap) await show($, recap)
239    else if ((await read($, card)) === null) {
240      if (!lastCleared) return { text: NOTHING }
241      await show($, lastCleared.recap)
242    }
243    const opened = await open($)
244
245    return opened.isPlaced ? {} : { text: `Session Recap is open but not drawn yet: ${opened.reason}` }
246  })
247
248  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249    // Until the new conversation's state holds it, the recap the module carried.
250    const recap = (await read($, card)) ?? lastCleared?.recap ?? null
251    const said = await read($, outcome)
252    if (!recap) {
253      const { Box, Text } = $.ui.resolve(e)
254
255      return (
256        <Box paddingX={1}>
257          <Text dimColor>{NOTHING}</Text>
258        </Box>
259      )
260    }
261
262    return e.surface === 'desktop' ? desktopPane($, $.ui.resolve(e), recap, said) : listPane($.ui.resolve(e), recap)
263  })
264}
265
hooks/card.ts 168 lines
1/** The recap as one drawing: a bento card of rounded tiles, animated for the pane and still for the image. Free of any engine call. */
2
3import { SMALL_TILES, tilesOf } from './stats'
4import type { Headline, Recap, Tile } from './stats'
5
6export const CARD = { width: 1200, height: 675 }
7
8const EDGE = 24
9const GAP = 12
10const COLUMNS = [484, 322, 322]
11const ROW = 201
12const CORNER = 28
13
14const GROUND = '#000000'
15const SURFACE = '#1C1C1E'
16const INK = '#F5F5F7'
17const GREY = '#8E8E93'
18const ACCENT = '#0A84FF'
19const PLUS = '#30D158'
20const MINUS = '#FF453A'
21
22// The system face: San Francisco in the Desktop app and under Quick Look.
23const FACE = "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif"
24// What one character of a semibold figure takes of its size, tracking included.
25const ADVANCE = 0.56
26
27// One tile after another: when the first starts, what each waits on the last, and how long one takes.
28const REVEAL = { first: 0.1, step: 0.09, takes: 0.7, rise: 16 }
29
30/** One tile's place on the grid of three columns and three rows, and what it shows. */
31export type Cell =
32  | { kind: 'headline' | 'credit'; column: number; row: number; columns: number; rows: number }
33  | { kind: 'accent' | 'small'; column: number; row: number; columns: number; rows: number; tile: Tile }
34
35// Where the small tiles go, in the order they are filled.
36const SLOTS = [
37  [2, 0],
38  [1, 1],
39  [2, 1],
40  [1, 2],
41  [2, 2],
42] as const
43
44/**
45 * Where each tile stands. With fewer than five small tiles the card closes
46 * up, so no cell is left empty and no tile shows a zero.
47 */
48export function layoutOf(recap: Recap): Cell[] {
49  const { accent, small } = tilesOf(recap)
50  const count = Math.min(small.length, SMALL_TILES)
51  const one = (kind: 'small' | 'accent', tile: Tile, column: number, row: number, columns = 1, rows = 1): Cell => ({ kind, column, row, columns, rows, tile })
52  // The fourth of four takes the last cell, the credit having taken the one before it.
53  const slots = count === 4 ? [SLOTS[0], SLOTS[1], SLOTS[2], SLOTS[4]] : SLOTS
54  const smalls =
55    count <= 2
56      ? [...(count === 2 ? [one('small', small[0] as Tile, 2, 0)] : []), ...(count >= 1 ? [one('small', small[count - 1] as Tile, 1, 1, 2)] : [])]
57      : small.slice(0, count).map((tile, i) => one('small', tile, slots[i]?.[0] ?? 2, slots[i]?.[1] ?? 2))
58
59  return [
60    { kind: 'headline', column: 0, row: 0, columns: 1, rows: 2 },
61    one('accent', accent, 1, 0, count <= 1 ? 2 : 1, count === 0 ? 2 : 1),
62    ...smalls,
63    { kind: 'credit', column: 0, row: 2, columns: count === 5 ? 1 : count === 4 ? 2 : 3, rows: 1 },
64  ]
65}
66
67const escape = (text: string) => text.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c] ?? c)
68
69const cut = (text: string, most: number) => (text.length > most ? `${text.slice(0, most)}…` : text)
70
71/** The size at which `text` fits `width`, no larger than `size` and no smaller than `least`. */
72const fit = (text: string, width: number, size: number, least: number) => Math.max(least, Math.min(size, Math.floor((width / (Math.max(1, text.length) * ADVANCE)) * 10) / 10))
73
74type Type = { size: number; fill?: string; weight?: number; tracking?: number; opacity?: number }
75
76function text(x: number, y: number, content: string, { size, fill = INK, weight = 400, tracking = 0, opacity }: Type): string {
77  const extras = `${tracking ? ` letter-spacing="${tracking}"` : ''}${opacity === undefined ? '' : ` fill-opacity="${opacity}"`}`
78
79  return `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}"${extras}>${content}</text>`
80}
81
82const span = (column: number, columns: number) => COLUMNS.slice(column, column + columns).reduce((sum, width) => sum + width, 0) + GAP * (columns - 1)
83const left = (column: number) => EDGE + COLUMNS.slice(0, column).reduce((sum, width) => sum + width + GAP, 0)
84
85function drawHeadline(headline: Headline, width: number, height: number): string {
86  const inset = 32
87  const size = fit(headline.time, width - 2 * inset, 128, 64)
88
89  return (
90    text(inset, 60, escape(cut(headline.project, 24)), { size: 27, weight: 600, tracking: -0.3 }) +
91    text(inset, 92, escape(headline.date), { size: 21, fill: GREY }) +
92    text(inset - 4, height - 78, escape(headline.time), { size, weight: 600, tracking: -size * 0.035 }) +
93    text(inset, height - 34, escape(headline.note), { size: 23, fill: GREY })
94  )
95}
96
97function drawTile(tile: Tile, isAccent: boolean, width: number, height: number): string {
98  const inset = 28
99  const room = width - 2 * inset
100  const shown = cut(tile.value, 20)
101  // The removed lines are written smaller, after a space.
102  const length = tile.removed ? `${shown} ${tile.removed}`.length - tile.removed.length * 0.4 : shown.length
103  const size = fit('x'.repeat(Math.ceil(length)), room, 58, 24)
104  const value = tile.removed
105    ? `<tspan fill="${PLUS}">${escape(shown)}</tspan> <tspan fill="${MINUS}" font-size="${Math.round(size * 0.6)}">${escape(tile.removed)}</tspan>`
106    : escape(shown)
107  const base = height - (tile.note ? 62 : 32)
108
109  return (
110    text(inset, 52, escape(tile.label), isAccent ? { size: 21, fill: '#FFFFFF', opacity: 0.86, weight: 500 } : { size: 21, fill: GREY, weight: 500 }) +
111    text(inset - 2, base, value, { size, weight: 600, fill: isAccent ? '#FFFFFF' : INK, tracking: -size * 0.03 }) +
112    (tile.note ? text(inset, height - 28, escape(tile.note), { size: 19, fill: isAccent ? '#FFFFFF' : GREY, ...(isAccent ? { opacity: 0.86 } : {}) }) : '')
113  )
114}
115
116// The mod's mark: a bento of four, one tile lit.
117const MARK = [0, 1, 2, 3].map(i => `<rect x="${28 + (i % 2) * 15}" y="${30 + Math.floor(i / 2) * 15}" width="12" height="12" rx="3.5" fill="${i === 1 ? ACCENT : '#48484A'}"/>`).join('')
118
119function drawCredit(height: number): string {
120  return MARK + text(28, height - 60, 'Session Recap', { size: 24, weight: 600, tracking: -0.2 }) + text(28, height - 30, 'by endless · Claude Code', { size: 19, fill: GREY })
121}
122
123/** How a tile comes in: unseen until its turn, then up into place. A surface that does not animate shows it as drawn. */
124function reveal(order: number): string {
125  const waits = REVEAL.first + order * REVEAL.step
126  const ends = waits + REVEAL.takes
127  const timing = `keyTimes="0;${(waits / ends).toFixed(3)};1" dur="${ends.toFixed(2)}s" fill="freeze" calcMode="spline" keySplines="0 0 1 1;0.16 1 0.3 1"`
128
129  return (
130    `<animate attributeName="opacity" values="0;0;1" ${timing}/>` +
131    `<animateTransform attributeName="transform" type="translate" values="0 ${REVEAL.rise};0 ${REVEAL.rise};0 0" ${timing}/>`
132  )
133}
134
135function body(recap: Recap, isAnimated: boolean): string {
136  const { headline } = tilesOf(recap)
137
138  return layoutOf(recap)
139    .map((cell, order) => {
140      const width = span(cell.column, cell.columns)
141      const height = ROW * cell.rows + GAP * (cell.rows - 1)
142      const inside =
143        'tile' in cell ? drawTile(cell.tile, cell.kind === 'accent', width, height) : cell.kind === 'headline' ? drawHeadline(headline, width, height) : drawCredit(height)
144      const tile = `<rect width="${width}" height="${height}" rx="${CORNER}" fill="${cell.kind === 'accent' ? ACCENT : SURFACE}"/>${inside}`
145
146      return `<g transform="translate(${left(cell.column)} ${EDGE + cell.row * (ROW + GAP)})">${isAnimated ? `<g>${reveal(order)}${tile}</g>` : tile}</g>`
147    })
148    .join('')
149}
150
151const open = (width: number, height: number) => `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FACE}">`
152
153/** The card as the pane shows it, its tiles coming in one after another. */
154export function cardSvg(recap: Recap): string {
155  return `${open(CARD.width, CARD.height)}<rect width="${CARD.width}" height="${CARD.height}" rx="${CORNER + EDGE / 2}" fill="${GROUND}"/>${body(recap, true)}</svg>`
156}
157
158/**
159 * The card as the image is made from it: still, and centred on a square of
160 * its own ground, because Quick Look draws a square and the frame is cut out
161 * of it afterwards.
162 */
163export function exportSvg(recap: Recap): string {
164  const side = CARD.width
165
166  return `${open(side, side)}<rect width="${side}" height="${side}" fill="${GROUND}"/><g transform="translate(0 ${(side - CARD.height) / 2})">${body(recap, false)}</g></svg>`
167}
168
hooks/export.ts 61 lines
1/** The card as an image: the commands that draw it with the tools macOS ships with, then copy or save it. Free of any engine call: the hooks run what this lays out, and only when a button is pressed. */
2
3import type { Recap } from '../types'
4import { CARD, exportSvg } from './card'
5
6// Two image pixels to each point of the card.
7const SCALE = 2
8const FOLDER = 'Session Recaps'
9
10/** What drawing the card to a PNG takes: a file to write, commands to run in order, and the working files to remove after. */
11export type Drawing = { svg: string; source: string; png: string; draw: string[][]; clean: string[] }
12
13/** Lays out the drawing of `recap` in the temporary folder `tmpdir` names. */
14export function drawingOf(recap: Recap, tmpdir: string | undefined): Drawing {
15  const dir = `${(tmpdir ?? '/tmp').replace(/\/+$/, '')}/session-recap`
16  const svg = `${dir}/card.svg`
17  const square = `${svg}.png`
18  const png = `${dir}/card.png`
19  const side = String(CARD.width * SCALE)
20
21  return {
22    svg,
23    source: exportSvg(recap),
24    png,
25    // Quick Look draws the system face, and only ever a square: sips cuts the card out of its middle.
26    draw: [
27      ['qlmanage', '-t', '-s', side, '-o', dir, svg],
28      ['sips', '-c', String(CARD.height * SCALE), side, square, '--out', png],
29    ],
30    clean: ['rm', '-f', svg, square, png],
31  }
32}
33
34/** The command that puts the PNG at `png` on the clipboard. */
35export const copyOf = (png: string): string[] => ['osascript', '-e', `set the clipboard to (read (POSIX file "${png.replace(/[\\"]/g, c => `\\${c}`)}") as «class PNGf»)`]
36
37const two = (value: number) => String(value).padStart(2, '0')
38
39/** `2026-01-05-1407`, in the machine's own time. */
40function stamp(ms: number): string {
41  const date = new Date(ms)
42
43  return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}-${two(date.getHours())}${two(date.getMinutes())}`
44}
45
46/** What saving the PNG takes: the commands that file it under Pictures, and the one that shows it in the Finder. */
47export function savingOf(recap: Recap, png: string, home: string): { where: string; file: string[][]; reveal: string[] } {
48  const folder = `${home}/Pictures/${FOLDER}`
49  const name = recap.project.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'session'
50  const path = `${folder}/${name}-${stamp(recap.at)}.png`
51
52  return {
53    where: `Pictures › ${FOLDER}`,
54    file: [
55      ['mkdir', '-p', folder],
56      ['cp', png, path],
57    ],
58    reveal: ['open', '-R', path],
59  }
60}
61
hooks/stats.ts 214 lines
1/** What a conversation did: the counts the hooks feed, the recap frozen from them, and how its numbers read. Free of any drawing or engine call. */
2
3import type { Recap, Stats } from '../types'
4
5export type { Recap, Stats }
6
7/** One finished tool call, as much of it as the counts need. */
8export type Call = {
9  tool: string
10  filePath?: string | undefined
11  /** What an edit replaced and what it wrote; a written file has only the second. */
12  oldText?: string | undefined
13  newText?: string | undefined
14  command?: string | undefined
15  skill?: string | undefined
16}
17
18/** One request's token counts, as the API spells them. */
19export type Usage = { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
20
21export type Tile = { label: string; value: string; note?: string; removed?: string }
22export type Headline = { project: string; date: string; time: string; note: string }
23export type Tiles = { headline: Headline; accent: Tile; small: Tile[] }
24
25export const SMALL_TILES = 5
26
27const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
28const SUBAGENTS = new Set(['Agent', 'Task'])
29const TEST_RUN =
30  /\b(vitest|jest|mocha|pytest|rspec|phpunit|playwright\s+test|go\s+test|cargo\s+test|bun\s+test|deno\s+test|claude\s+plugin\s+test|(npm|pnpm|yarn)\s+(run\s+)?test\b|npm\s+t\b)/
31
32export const isTestRun = (command: string) => TEST_RUN.test(command)
33
34export const emptyStats = (costAtStart: number | null): Stats => ({
35  costAtStart,
36  workedMs: 0,
37  turns: 0,
38  tools: {},
39  files: [],
40  added: 0,
41  removed: 0,
42  testRuns: 0,
43  subagents: 0,
44  skills: [],
45  connectors: [],
46  tokens: 0,
47  model: '',
48})
49
50const linesOf = (text: string) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
51
52/** The lines an edit added and removed, the ones it kept at either end left out. An estimate, not a diff. */
53function changed(oldText: string, newText: string): { added: number; removed: number } {
54  const before = linesOf(oldText)
55  const after = linesOf(newText)
56  const most = Math.min(before.length, after.length)
57  let head = 0
58  while (head < most && before[head] === after[head]) head++
59  let tail = 0
60  while (tail < most - head && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++
61
62  return { added: after.length - head - tail, removed: before.length - head - tail }
63}
64
65/** `mcp__claude_ai_Gmail__search_threads` is the server `claude_ai_Gmail` and the tool `search_threads`. */
66function mcp(tool: string): { server: string; name: string } | null {
67  const parts = tool.split('__')
68
69  return parts[0] === 'mcp' && parts.length >= 3 ? { server: parts[1] ?? '', name: parts.slice(2).join('__') } : null
70}
71
72const once = (list: string[], one: string | undefined) => (one && !list.includes(one) ? [...list, one] : list)
73
74/** Folds one finished tool call into the counts. */
75export function withCall(stats: Stats, call: Call): Stats {
76  const isEdit = EDITS.has(call.tool)
77  const lines = isEdit && call.newText !== undefined ? changed(call.oldText ?? '', call.newText) : { added: 0, removed: 0 }
78
79  return {
80    ...stats,
81    tools: { ...stats.tools, [call.tool]: (stats.tools[call.tool] ?? 0) + 1 },
82    files: isEdit ? once(stats.files, call.filePath) : stats.files,
83    added: stats.added + lines.added,
84    removed: stats.removed + lines.removed,
85    testRuns: stats.testRuns + (call.tool === 'Bash' && call.command && isTestRun(call.command) ? 1 : 0),
86    subagents: stats.subagents + (SUBAGENTS.has(call.tool) ? 1 : 0),
87    skills: call.tool === 'Skill' ? once(stats.skills, call.skill) : stats.skills,
88    connectors: once(stats.connectors, mcp(call.tool)?.server),
89  }
90}
91
92/** Folds one finished turn of the main conversation into the counts. */
93export const withTurn = (stats: Stats, durationMs: number): Stats => ({ ...stats, turns: stats.turns + 1, workedMs: stats.workedMs + Math.max(0, durationMs) })
94
95/** Folds one request of the main conversation into the counts. */
96export const withStep = (stats: Stats, usage: Usage): Stats => ({
97  ...stats,
98  tokens: stats.tokens + usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
99  model: usage.model || stats.model,
100})
101
102/** The most called tool, the first by name among equals; an MCP tool without its server. */
103function topTool(tools: Record<string, number>): string | null {
104  const [first] = Object.entries(tools).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
105
106  return first ? (mcp(first[0])?.name ?? first[0]) : null
107}
108
109/** `claude-opus-5-5[1m]` is `Opus 5.5`; a name already written out passes through. */
110export function modelLabel(model: string): string {
111  const id = model.replace(/\[.*\]$/, '').trim()
112  if (!id.startsWith('claude-')) return id
113  const parts = id.split('-').slice(1).filter(part => !/^\d{8}$/.test(part))
114  const family = parts.find(part => /^[a-z]+$/i.test(part))
115  const version = parts.filter(part => /^\d+$/.test(part)).join('.')
116
117  return family ? `${family[0]?.toUpperCase()}${family.slice(1)}${version ? ` ${version}` : ''}` : id
118}
119
120/** Freezes the counts into what the card shows; `cost` is what the session has cost by now. */
121export function recapOf(stats: Stats, now: { now: number; cwd: string; cost: number | null; isSoFar: boolean }): Recap {
122  return {
123    project: now.cwd.split(/[\\/]/).filter(Boolean).pop() ?? '',
124    at: now.now,
125    model: modelLabel(stats.model),
126    workedMs: stats.workedMs,
127    turns: stats.turns,
128    toolCalls: Object.values(stats.tools).reduce((sum, calls) => sum + calls, 0),
129    topTool: topTool(stats.tools),
130    files: stats.files.length,
131    added: stats.added,
132    removed: stats.removed,
133    testRuns: stats.testRuns,
134    subagents: stats.subagents,
135    skills: stats.skills.length,
136    connectors: stats.connectors.length,
137    tokens: stats.tokens,
138    cost: now.cost === null || stats.costAtStart === null ? null : Math.max(0, now.cost - stats.costAtStart),
139    isSoFar: now.isSoFar,
140  }
141}
142
143/** `1h 42m`, `14m`, `45s`. */
144export function span(ms: number): string {
145  const minutes = Math.floor(ms / 60_000)
146  if (minutes < 1) return `${Math.max(0, Math.round(ms / 1000))}s`
147  if (minutes < 60) return `${minutes}m`
148
149  return `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${minutes % 60}m` : ''}`
150}
151
152const UNITS: [number, string][] = [
153  [1e9, 'B'],
154  [1e6, 'M'],
155  [1e3, 'K'],
156]
157
158/** `864`, `1,077`, `12.4K`, `2.1M`. */
159export function count(value: number): string {
160  if (value < 10_000) return String(Math.round(value)).replace(/\B(?=(\d{3})+$)/g, ',')
161  const [size, unit] = UNITS.find(([floor]) => value >= floor) ?? [1, '']
162  const scaled = value / size
163
164  return `${scaled >= 100 ? Math.round(scaled) : scaled.toFixed(1).replace(/\.0$/, '')}${unit}`
165}
166
167/** `$6.40`, and `$142` from a hundred dollars. */
168export const dollars = (usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
169
170const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
171
172/** `5 Jan 2026`, in the machine's own time. */
173export function dateLabel(ms: number): string {
174  const date = new Date(ms)
175
176  return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`
177}
178
179/** What each tile of the card says. No tile shows a zero: one with nothing to say gives its place to the next. */
180export function tilesOf(recap: Recap): Tiles {
181  const hasCalls = recap.toolCalls > 0
182  const hasCost = recap.cost !== null && recap.cost >= 0.005
183  const tokens = recap.tokens > 0 ? count(recap.tokens) : null
184  // Tokens stand on the accent tile when no tool was called, so nowhere else then.
185  const spareTokens = hasCalls ? tokens : null
186  const turns = `${count(recap.turns)} turn${recap.turns === 1 ? '' : 's'}`
187
188  const candidates: (Tile | false | null)[] = [
189    recap.files > 0 && { label: 'Files edited', value: count(recap.files) },
190    recap.added + recap.removed > 0 && { label: 'Lines', value: `+${count(recap.added)}`, removed: `−${count(recap.removed)}` },
191    recap.topTool !== null && { label: 'Most used', value: recap.topTool },
192    recap.testRuns > 0 && { label: 'Test runs', value: count(recap.testRuns) },
193    hasCost && { label: 'Cost', value: dollars(recap.cost ?? 0), ...(spareTokens ? { note: `${spareTokens} tokens` } : {}) },
194    recap.subagents > 0 && { label: 'Subagents', value: count(recap.subagents) },
195    recap.skills > 0 && { label: 'Skills', value: count(recap.skills) },
196    recap.connectors > 0 && { label: 'Connectors', value: count(recap.connectors) },
197    !hasCost && spareTokens !== null && { label: 'Tokens', value: spareTokens },
198  ]
199
200  return {
201    headline: { project: recap.project, date: `${recap.isSoFar ? 'So far · ' : ''}${dateLabel(recap.at)}`, time: span(recap.workedMs), note: recap.model ? `${turns} on ${recap.model}` : turns },
202    accent: hasCalls ? { label: 'Tool calls', value: count(recap.toolCalls) } : { label: 'Tokens', value: tokens ?? '0' },
203    small: candidates.filter((tile): tile is Tile => Boolean(tile)).slice(0, SMALL_TILES),
204  }
205}
206
207/** The card in a sentence or two, for someone who cannot see it. */
208export function altOf(recap: Recap): string {
209  const { headline, accent, small } = tilesOf(recap)
210  const said = [accent, ...small].map(tile => `${tile.label}: ${tile.value}${tile.removed ? ` ${tile.removed}` : ''}${tile.note ? `, ${tile.note}` : ''}.`)
211
212  return `Session recap of ${headline.project}, ${headline.date}: ${headline.time} worked, ${headline.note}. ${said.join(' ')}`
213}
214
types/index.d.ts 61 lines
1/** The mod's values in `$.state`: the session's, kept through a reload of the mod. */
2
3/** The counts of one conversation, from the session's start or the last /clear. */
4export type Stats = {
5  /** Dollars the session had cost when the conversation began; null where nothing keeps count. */
6  costAtStart: number | null
7  /** The time Claude spent working, summed over the main conversation's turns. */
8  workedMs: number
9  turns: number
10  /** Calls by tool name, the main conversation's and its subagents'. */
11  tools: Record<string, number>
12  /** The paths edited, each once. They never leave this value: the card shows how many. */
13  files: string[]
14  added: number
15  removed: number
16  testRuns: number
17  subagents: number
18  skills: string[]
19  connectors: string[]
20  tokens: number
21  /** The model that answered the main conversation's last request, by the id the API reports. */
22  model: string
23}
24
25/** A conversation as the card shows it: figures only, and the project's folder name. */
26export type Recap = {
27  project: string
28  /** When it was frozen, in the clock's milliseconds. */
29  at: number
30  model: string
31  workedMs: number
32  turns: number
33  toolCalls: number
34  topTool: string | null
35  files: number
36  added: number
37  removed: number
38  testRuns: number
39  subagents: number
40  skills: number
41  connectors: number
42  tokens: number
43  /** Dollars this conversation cost; null where nothing keeps count. */
44  cost: number | null
45  /** True for a conversation still running, shown by /recap before any /clear. */
46  isSoFar: boolean
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'session-recap': {
52      /** The running conversation's counts; null until the session's start gave it a cost to count from. */
53      stats: Stats | null
54      /** What the pane shows: the last conversation cleared, or the running one when /recap asked for it. */
55      card: Recap | null
56      /** What the last press of Copy image or Save image came to; empty before any. */
57      outcome: string
58    }
59  }
60}
61