SLOPSHOPPER

session-recap

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

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 │ ┃ Session recap │ ┃ ⏺ Read(src/auth.ts) │ ┃ - Duration: 30m ⎿ Read 6 lines │ ┃ - Cost: $0.42 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Files changed (3) ⏺ Bash(bun test) │ ┃ - `src/auth.ts` ⎿ 3 pass, 1 fail │ ┃ - `src/audit.ts` │ ┃ - `src/cache.ts` ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Commits (0) ✻ Worked for 42s · done 4:20 PM │ ┃ - none │ ┃ › /recap │ ┃ Pull requests (0) ⎿ session-recap: Session recap pane opened (/recap text prints it │ ┃ - none │ ┃ │ ┃ Tests & builds (0) │ ┃ - none │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session recap
Session recap - Duration: 30m - Cost: $0.42 Files changed (3) - `src/auth.ts` - `src/audit.ts` - `src/cache.ts` Commits (0) - none Pull requests (0) - none Tests & builds (0) - none
README

session-recap

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.

What it does

  • Tracks, for the main conversation (subagent tool calls are ignored):
  • files changed: distinct paths of successful Edit, Write and NotebookEdit calls, relative to the session folder
  • commits: each successful Bash git 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 answer
  • pull requests: successful gh pr create / gh pr merge, with the number and URL taken from a …/pull/N link in the command or its output
  • tests & builds: runs of npm/pnpm/yarn test, vitest, jest, pytest, cargo test, go test, swift test, xcodebuild, tsc and claude plugin test, counted as passed or failed by the command's exit status
  • Commands are recognised behind rtk (including rtk proxy|test|err|summary), npx and its -y/-p <pkg> flags, sudo, time, env and VAR=value prefixes.
  • Bash calls run with 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.
  • Adds the session duration and cost ($.session.usage()).
  • The data lives in the session state, so it survives a hot reload of the mod.

Commands

CommandEffect
/recapOpens the recap pane
/recap textPrints the recap as Markdown (copyable command output)
/recap closeCloses the pane
/recap clearEmpties the tracked data

Install

claude --plugin-dir /path/to/ModsTools/mods/session-recap

Limits

  • Pass/fail is per command run (exit status), not per test case: test-runner output is never parsed, since it may be localised or rewritten by RTK.
  • A command chaining several checks counts each of them with the command's single exit status.
  • Commands are classified by their text: a commit made by a script or an alias is not seen. A Bash cd from an earlier call is not tracked; commits run in the session folder unless the same command says otherwise.
  • Only the first git commit of a chained command is recorded.
  • Only what happens after the mod loads is tracked.

Develop

claude plugin validate mods/session-recap
claude plugin test mods/session-recap   # 13 tests
Source 3 files
hooks/register.tsx 128 lines
1import { 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}
128
hooks/recap.ts 226 lines
1// 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}
226
types/index.d.ts 19 lines
1export 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