/recap: a pane (or copyable Markdown) summarising the session: duration, cost, files changed, commits, PRs and test/build runs

A one-glance summary of what the session did, as a pane or as Markdown you can copy into a PR, a journal or a hand-off note.
Edit, Write and NotebookEdit calls, relative to the session foldergit commit (also behind rtk, -C dir or a preceding cd dir &&), read back with git log -1 --format=%h %s in that folder; the -m subject is used when git cannot answergh pr create / gh pr merge, with the number and URL taken from a …/pull/N link in the command or its outputclaude plugin test, counted as passed or failed by the command's exit statusrtk (including rtk proxy|test|err|summary), npx and its -y/-p <pkg> flags, sudo, time, env and VAR=value prefixes.run_in_background are ignored: they return before the command ends, so there is no exit status yet and a commit may not have landed.$.session.usage()).| Command | Effect |
|---|---|
/recap | Opens the recap pane |
/recap text | Prints the recap as Markdown (copyable command output) |
/recap close | Closes the pane |
/recap clear | Empties the tracked data |
claude --plugin-dir /path/to/ModsTools/mods/session-recap
cd from an earlier call is not tracked; commits run in the session folder unless the same command says otherwise.git commit of a chained command is recorded.claude plugin validate mods/session-recap
claude plugin test mods/session-recap # 13 testshooks/register.tsx 128 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Commit, RecapLog } from '../types'
5import {
6 EMPTY,
7 addChecks,
8 addCommit,
9 addFile,
10 addPr,
11 checkKinds,
12 parseCommit,
13 parseLogLine,
14 parsePr,
15 recap,
16 relative,
17 resolveDir,
18 withOutput,
19} from './recap'
20
21const PANE = 'session-recap'
22const TITLE = 'Session recap'
23const log = atom({ plugin: 'session-recap', key: 'log' } as const, EMPTY)
24
25async function lines($: EngineInterface): Promise<string[]> {
26 const usage = await $.session.usage()
27 const now = await $.clock.now()
28 const data: RecapLog = await read($, log)
29
30 return recap(data, { startedAt: usage.startedAt, ...(usage.cost === undefined ? {} : { usd: usage.cost.usd }) }, now)
31}
32
33// The commit just made, read back with `git log`; the `-m` subject when git cannot answer.
34async function lastCommit($: EngineInterface, dir: string, fallback: string | undefined): Promise<Commit | null> {
35 try {
36 const out = await $.process.run(['git', 'log', '-1', '--format=%h %s'], { cwd: dir })
37 if (out.exitCode === 0) {
38 const commit = parseLogLine(out.stdout)
39 if (commit !== null) return commit
40 }
41 } catch {
42 // fall through to the -m subject
43 }
44
45 return fallback === undefined ? null : { sha: '', subject: fallback }
46}
47
48async function onBash($: EngineInterface, command: string, ok: boolean, output: string): Promise<void> {
49 const kinds = checkKinds(command)
50 if (kinds.length > 0) await update($, log, l => addChecks(l, kinds, ok))
51 if (!ok) return
52 const commit = parseCommit(command)
53 if (commit !== null) {
54 const found = await lastCommit($, resolveDir(commit.dir, await $.session.cwd()), commit.message)
55 if (found !== null) await update($, log, l => addCommit(l, found))
56 }
57 const pr = parsePr(command)
58 if (pr !== null) {
59 const entry = withOutput(pr, output)
60 await update($, log, l => addPr(l, entry))
61 }
62}
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 await $.command.register({
67 name: 'recap',
68 description: 'Session recap pane: duration, cost, files, commits, PRs, tests (text | close | clear)',
69 })
70
71 return next(e)
72 })
73
74 on('command.run', { command: 'recap' }, async ($, e) => {
75 const arg = e.args.trim()
76 if (arg === 'text') return { text: (await lines($)).join('\n') }
77 if (arg === 'close' || arg === 'off') {
78 await $.ui.close({ id: PANE })
79 return { text: 'Session recap pane closed.' }
80 }
81 if (arg === 'clear') {
82 await update($, log, () => EMPTY)
83 return { text: 'Session recap cleared.' }
84 }
85 await $.ui.open({ id: PANE, title: TITLE })
86
87 return { text: 'Session recap pane opened (/recap text prints it as Markdown).' }
88 })
89
90 // Main conversation only: subagent tool calls carry an agentId.
91 on('tool.call', async ($, e, next) => {
92 const ran = await next(e)
93 if (e.agentId !== undefined || ran.deny !== undefined) return ran
94 const ok = ran.isError !== true
95 if (e.tool === 'Edit' || e.tool === 'Write') {
96 if (ok) {
97 const path = relative(e.file_path, await $.session.cwd())
98 await update($, log, l => addFile(l, path))
99 }
100 } else if (e.tool === 'NotebookEdit') {
101 if (ok) {
102 const path = relative(e.notebook_path, await $.session.cwd())
103 await update($, log, l => addFile(l, path))
104 }
105 } else if (e.tool === 'Bash' && e.run_in_background !== true) {
106 // A background command returns before it ends: no exit status yet, and a commit may not have landed.
107 const stdout = (ran.result as { stdout?: unknown } | undefined)?.stdout
108 const output = `${ran.text ?? ''}\n${typeof stdout === 'string' ? stdout : ''}`
109 await onBash($, e.command, ok, output)
110 }
111
112 return ran
113 })
114
115 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
116 const { Box, Text } = $.ui.resolve(e)
117 const all = await lines($)
118
119 return (
120 <Box flexDirection="column">
121 {all.map(line =>
122 line.startsWith('#') ? <Text bold>{line.replace(/^#+\s*/, '')}</Text> : <Text>{line === '' ? ' ' : line}</Text>,
123 )}
124 </Box>
125 )
126 })
127}
128hooks/recap.ts 226 lines1// Pure logic: classify Bash commands, accumulate the session log, render the recap.
2import type { CheckTally, Commit, PullRequest, RecapLog } from '../types'
3
4export const EMPTY: RecapLog = { files: [], commits: [], prs: [], checks: [] }
5
6// Splits a shell command on && || ; | and newlines, outside quotes.
7export function segments(command: string): string[] {
8 const out: string[] = []
9 let current = ''
10 let quote: string | null = null
11 for (let i = 0; i < command.length; i++) {
12 const c = command[i] ?? ''
13 if (quote !== null) {
14 if (c === quote) quote = null
15 current += c
16 continue
17 }
18 if (c === '"' || c === "'") {
19 quote = c
20 current += c
21 continue
22 }
23 if (c === ';' || c === '\n' || c === '|' || (c === '&' && command[i + 1] === '&')) {
24 if (c === '&' || (c === '|' && command[i + 1] === '|')) i++
25 out.push(current)
26 current = ''
27 continue
28 }
29 current += c
30 }
31 out.push(current)
32
33 return out.map(s => s.trim()).filter(Boolean)
34}
35
36const WRAPPERS = /^(?:rtk(?:\s+(?:proxy|test|err|summary)(?=\s))?|sudo|bunx|time|env|nohup)\s+/
37const NPX = /^npx(?:\s+(?:-y|--yes|-p\s+\S+|--package(?:=|\s+)\S+))*\s+/
38const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/
39
40// Strips `rtk` (and its proxy/test/err/summary forms), npx and its flags, env assignments and common wrappers.
41export function strip(segment: string): string {
42 let rest = segment.trim()
43 for (;;) {
44 const next = rest.replace(WRAPPERS, '').replace(NPX, '').replace(ENV_ASSIGN, '')
45 if (next === rest) return rest
46 rest = next
47 }
48}
49
50const unquote = (s: string) => s.replace(/^(['"])([\s\S]*)\1$/, '$2')
51
52const CHECKS: [string, RegExp][] = [
53 ['npm test', /^npm\s+(?:run\s+)?test\b/],
54 ['pnpm test', /^pnpm\s+(?:run\s+)?test\b/],
55 ['yarn test', /^yarn\s+(?:run\s+)?test\b/],
56 ['vitest', /^vitest\b/],
57 ['jest', /^jest\b/],
58 ['pytest', /^(?:pytest|python3?\s+-m\s+pytest)\b/],
59 ['cargo test', /^cargo\s+test\b/],
60 ['go test', /^go\s+test\b/],
61 ['swift test', /^swift\s+test\b/],
62 ['xcodebuild', /^xcodebuild\b/],
63 ['tsc', /^tsc\b/],
64 ['claude plugin test', /^claude\s+plugin\s+test\b/],
65]
66
67// The distinct test/build kinds a command runs, in order of appearance.
68export function checkKinds(command: string): string[] {
69 const kinds: string[] = []
70 for (const seg of segments(command)) {
71 const rest = strip(seg)
72 const found = CHECKS.find(([, re]) => re.test(rest))
73 if (found !== undefined && !kinds.includes(found[0])) kinds.push(found[0])
74 }
75
76 return kinds
77}
78
79export type CommitCall = { dir?: string; message?: string }
80
81const GIT_COMMIT = /^git\s+((?:(?:-C|-c)\s+(?:"[^"]*"|'[^']*'|\S+)\s+)*)commit\b(.*)$/s
82
83// A `git commit` in the command: the folder it runs in (`-C dir`, or a preceding `cd dir`) and its `-m` subject.
84export function parseCommit(command: string): CommitCall | null {
85 let cdDir: string | undefined
86 for (const seg of segments(command)) {
87 const rest = strip(seg)
88 const cd = /^cd\s+("[^"]*"|'[^']*'|\S+)\s*$/.exec(rest)
89 if (cd !== null) {
90 cdDir = unquote(cd[1] ?? '')
91 continue
92 }
93 const m = GIT_COMMIT.exec(rest)
94 if (m === null) continue
95 const opts = m[1] ?? ''
96 const c = /-C\s+("[^"]*"|'[^']*'|\S+)/.exec(opts)
97 const dir = c !== null ? unquote(c[1] ?? '') : cdDir
98 const msg = /(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:\s+|=)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/.exec(m[2] ?? '')
99 const message = msg === null ? undefined : unquote(msg[1] ?? '').split('\n')[0]?.trim()
100 const call: CommitCall = {}
101 if (dir !== undefined) call.dir = dir
102 if (message !== undefined && message !== '') call.message = message
103
104 return call
105 }
106
107 return null
108}
109
110// Resolves a commit folder against the session folder.
111export function resolveDir(dir: string | undefined, cwd: string): string {
112 if (dir === undefined || dir === '' || dir === '.') return cwd
113 if (dir.startsWith('/')) return dir
114
115 return `${cwd.replace(/\/$/, '')}/${dir}`
116}
117
118// "<sha> <subject>" as `git log -1 --format=%h %s` prints it.
119export function parseLogLine(stdout: string): Commit | null {
120 const line = stdout.trim().split('\n')[0] ?? ''
121 const m = /^([0-9a-f]{4,40})\s+(.*)$/.exec(line)
122
123 return m === null ? null : { sha: m[1] ?? '', subject: m[2] ?? '' }
124}
125
126export type PrCall = { action: 'created' | 'merged'; number?: number; url?: string }
127
128const PR_URL = /https?:\/\/\S+?\/pull\/(\d+)/
129
130// A `gh pr create|merge` in the command, with any number or URL given as an argument.
131export function parsePr(command: string): PrCall | null {
132 for (const seg of segments(command)) {
133 const m = /^gh\s+pr\s+(create|merge)\b(.*)$/s.exec(strip(seg))
134 if (m === null) continue
135 const call: PrCall = { action: m[1] === 'create' ? 'created' : 'merged' }
136 const args = m[2] ?? ''
137 const url = PR_URL.exec(args)
138 if (url !== null) {
139 call.url = url[0]
140 call.number = Number(url[1])
141 } else if (call.action === 'merged') {
142 const num = /(?:^|\s)#?(\d+)(?=\s|$)/.exec(args)
143 if (num !== null) call.number = Number(num[1])
144 }
145
146 return call
147 }
148
149 return null
150}
151
152// Completes a PR call with the URL found in the command's output, when there is one.
153export function withOutput(call: PrCall, output: string): PullRequest {
154 const url = PR_URL.exec(output)
155 if (url === null || call.url !== undefined) return { ...call }
156
157 return { ...call, url: url[0], number: Number(url[1]) }
158}
159
160export const relative = (path: string, cwd: string): string => {
161 const root = cwd.replace(/\/$/, '')
162 return path.startsWith(root + '/') ? path.slice(root.length + 1) : path
163}
164
165export const addFile = (log: RecapLog, path: string): RecapLog =>
166 log.files.includes(path) ? log : { ...log, files: [...log.files, path] }
167
168export const addCommit = (log: RecapLog, commit: Commit): RecapLog => ({ ...log, commits: [...log.commits, commit] })
169
170export function addPr(log: RecapLog, pr: PullRequest): RecapLog {
171 const same = (p: PullRequest) => p.action === pr.action && p.number !== undefined && p.number === pr.number
172 return log.prs.some(same) ? log : { ...log, prs: [...log.prs, pr] }
173}
174
175export function addChecks(log: RecapLog, kinds: string[], ok: boolean): RecapLog {
176 let checks: CheckTally[] = log.checks
177 for (const kind of kinds) {
178 const found = checks.find(c => c.kind === kind)
179 const next: CheckTally = {
180 kind,
181 pass: (found?.pass ?? 0) + (ok ? 1 : 0),
182 fail: (found?.fail ?? 0) + (ok ? 0 : 1),
183 }
184 checks = found === undefined ? [...checks, next] : checks.map(c => (c.kind === kind ? next : c))
185 }
186
187 return { ...log, checks }
188}
189
190export function duration(ms: number): string {
191 const min = Math.max(0, Math.floor(ms / 60_000))
192
193 return min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
194}
195
196const prLine = (pr: PullRequest) => {
197 const id = pr.number === undefined ? '' : ` #${pr.number}`
198 const url = pr.url === undefined ? '' : ` ${pr.url}`
199 return `- ${pr.action}${id}${url}`
200}
201
202const runs = (n: number) => `${n} run${n === 1 ? '' : 's'}`
203
204// The recap as Markdown lines.
205export function recap(log: RecapLog, usage: { startedAt: number; usd?: number }, now: number): string[] {
206 const section = (title: string, items: string[]) => [
207 '',
208 `### ${title} (${items.length})`,
209 ...(items.length === 0 ? ['- none'] : items),
210 ]
211
212 return [
213 '## Session recap',
214 '',
215 `- Duration: ${duration(now - usage.startedAt)}`,
216 `- Cost: ${usage.usd === undefined ? 'not available' : `$${usage.usd.toFixed(2)}`}`,
217 ...section('Files changed', log.files.map(f => `- \`${f}\``)),
218 ...section('Commits', log.commits.map(c => (c.sha === '' ? `- ${c.subject}` : `- \`${c.sha}\` ${c.subject}`))),
219 ...section('Pull requests', log.prs.map(prLine)),
220 ...section(
221 'Tests & builds',
222 log.checks.map(c => `- ${c.kind}: ${runs(c.pass + c.fail)} · ${c.pass} passed · ${c.fail} failed`),
223 ),
224 ]
225}
226types/index.d.ts 19 lines1export type Commit = { sha: string; subject: string }
2
3export type PullRequest = { action: 'created' | 'merged'; number?: number; url?: string }
4
5export type CheckTally = { kind: string; pass: number; fail: number }
6
7export type RecapLog = {
8 files: string[]
9 commits: Commit[]
10 prs: PullRequest[]
11 checks: CheckTally[]
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'session-recap': { log: RecapLog }
17 }
18}
19