Replay a past Claude session of this project step by step in a pane: /replay

Mods for Claude Code: plugins that change its own interface.
| Mod | Command | What it does |
|---|---|---|
| Agent Sankey | /sankey | Draws an orchestrator session and the sessions it started as a Sankey diagram |
| Replay Theater | /replay | Plays back a past session of the current project step by step |
At the prompt of a Claude Code terminal session:
/plugin install agent-sankey --marketplace judioo/claude-mods
/plugin install replay-theater --marketplace judioo/claude-mods
Answer y to add the marketplace, then pick a scope (user scope makes the mod available in every session). Installed at the user scope from a terminal, the mods also load in the desktop app's Code tab.

Type /sankey and the diagram draws inline in the conversation:
Hover a bar or band to trace it and read its count. Buttons above the diagram switch between your most recently active orchestrators; buttons below open any session in the family. /sankey <name> picks the orchestrator whose title contains <name>.
Requirements: the Claude desktop app on macOS, and python3 on the path. The mod reads the desktop app's session records under ~/Library/Application Support/Claude/claude-code-sessions and takes only titles, which session started which, turn counts, archive and pull-request state and last activity. It never reads what was said in a session. In a plain terminal the diagram is drawn as an indented text tree.
Type /replay to open a pane listing the 15 most recent sessions of the current project. Pick one to play it back: your prompts, Claude's replies, each tool call and its result. Play, pause, step back and forth, jump to either end, and change speed (1x, 2x, 4x). Each control has a key: p play, n next, b back, h start, e end, s speed, l back to the list.
Thinking and subagent activity are left out, and only the first 4 MB of a very large session is replayed.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module. Check and test one with:
claude plugin validate agent-sankey
claude plugin test agent-sankey
hooks/register.tsx 282 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Frame, SessionInfo, View } from '../types'
5import { parseTranscript, projectFolderName, titleOf } from './parse'
6
7const PANE = 'replay-theater'
8const TITLE = 'Replay Theater'
9const TICK_MS = 400
10const MAX_READ = 4 * 1024 * 1024 - 64 * 1024
11const LISTED = 15
12
13const view = atom({ plugin: 'replay-theater', key: 'view' } as const, 'list')
14const sessions = atom({ plugin: 'replay-theater', key: 'sessions' } as const, [])
15const title = atom({ plugin: 'replay-theater', key: 'title' } as const, '')
16const frames = atom({ plugin: 'replay-theater', key: 'frames' } as const, [])
17const cursor = atom({ plugin: 'replay-theater', key: 'cursor' } as const, 0)
18const isPlaying = atom({ plugin: 'replay-theater', key: 'isPlaying' } as const, false)
19const speed = atom({ plugin: 'replay-theater', key: 'speed' } as const, 1)
20const note = atom({ plugin: 'replay-theater', key: 'note' } as const, null)
21
22const SPEEDS = [1, 2, 4]
23
24const LABEL: Record<Frame['kind'], { name: string; color: string }> = {
25 prompt: { name: 'You', color: 'suggestion' },
26 reply: { name: 'Claude', color: 'claude' },
27 tool: { name: 'Tool', color: 'warning' },
28 result: { name: 'Result', color: 'subtle' },
29}
30
31const transcriptsFolder = async ($: EngineInterface): Promise<string> => {
32 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
33 const home = (await $.env.get('HOME')) ?? ''
34 const config = configured ?? `${home}/.claude`
35 return `${config}/projects/${projectFolderName(await $.session.root())}`
36}
37
38// The start of a file, for one too large to read whole or when only its title is needed.
39const head = async ($: EngineInterface, path: string, bytes: number): Promise<string> => {
40 const { stdout } = await $.process.run(['head', '-c', String(bytes), path])
41 return stdout
42}
43
44const loadList = async ($: EngineInterface): Promise<void> => {
45 await update($, note, () => 'Looking for sessions…')
46 try {
47 const folder = await transcriptsFolder($)
48 const current = await $.session.id()
49 const entries = (await $.fs.list(folder))
50 .filter(one => one.kind === 'file' && one.name.endsWith('.jsonl') && one.size > 0)
51 .sort((a, b) => b.mtimeMs - a.mtimeMs)
52 .slice(0, LISTED)
53 const found: SessionInfo[] = []
54 for (const one of entries) {
55 const id = one.name.slice(0, -'.jsonl'.length)
56 const path = `${folder}/${one.name}`
57 const text = await head($, path, 256 * 1024).catch(() => '')
58 found.push({ id, title: titleOf(text), mtimeMs: one.mtimeMs, size: one.size, isCurrent: id === current })
59 }
60 await update($, sessions, () => found)
61 await update($, note, () => (found.length === 0 ? 'No past sessions found for this project.' : null))
62 } catch (error) {
63 await update($, note, () => `Could not list sessions: ${String(error)}`)
64 }
65}
66
67const openSession = async ($: EngineInterface, session: SessionInfo): Promise<void> => {
68 await update($, isPlaying, () => false)
69 await update($, note, () => 'Loading…')
70 try {
71 const path = `${await transcriptsFolder($)}/${session.id}.jsonl`
72 const isWhole = session.size <= MAX_READ
73 const text = isWhole ? await $.fs.read(path) : await head($, path, MAX_READ)
74 const parsed = parseTranscript(text)
75 await update($, frames, () => parsed)
76 await update($, title, () => session.title)
77 await update($, cursor, () => 0)
78 await update($, view, () => 'stage')
79 await update($, note, () => (isWhole ? null : 'Large session: only its first 4 MB is replayed.'))
80 } catch (error) {
81 await update($, note, () => `Could not open that session: ${String(error)}`)
82 }
83}
84
85const step = async ($: EngineInterface, by: number): Promise<void> => {
86 const count = (await read($, frames)).length
87 await update($, cursor, at => Math.max(0, Math.min(count - 1, at + by)))
88}
89
90const togglePlay = async ($: EngineInterface): Promise<void> => {
91 const count = (await read($, frames)).length
92 if ((await read($, cursor)) >= count - 1) await update($, cursor, () => 0)
93 await update($, isPlaying, on => !on)
94}
95
96// Cuts text to whole lines that fit the pane.
97const clip = (text: string, lines: number, columns: number): string[] => {
98 const out: string[] = []
99 for (const raw of text.split('\n')) {
100 let rest = raw
101 do {
102 out.push(rest.slice(0, columns))
103 rest = rest.slice(columns)
104 } while (rest.length > 0 && out.length < lines)
105 if (out.length >= lines) break
106 }
107 const isCut = out.length >= lines && out.join('\n').length < text.length
108 const last = out.at(-1)
109 if (isCut && last !== undefined) out[out.length - 1] = `${last.slice(0, Math.max(0, columns - 1))}…`
110 return out.slice(0, lines)
111}
112
113const when = (iso: string | undefined): string => {
114 if (iso === undefined) return ''
115 const date = new Date(iso)
116 return Number.isNaN(date.getTime()) ? '' : date.toTimeString().slice(0, 8)
117}
118
119const ago = (ms: number, now: number): string => {
120 const minutes = Math.round((now - ms) / 60000)
121 if (minutes < 60) return `${minutes}m ago`
122 if (minutes < 60 * 48) return `${Math.round(minutes / 60)}h ago`
123 return `${Math.round(minutes / 1440)}d ago`
124}
125
126const sizeOf = (bytes: number): string =>
127 bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
128
129export const register: Register = on => {
130 let ticks = 0
131
132 on('session.start', async ($, e, next) => {
133 await $.command.register({
134 name: 'replay',
135 description: 'Replay a past session of this project step by step in a pane',
136 })
137 await update($, isPlaying, () => false)
138
139 // One heartbeat drives playback: at 1x a frame every 1.6 s, 4x every 0.4 s.
140 $.clock.every(TICK_MS, () => {
141 void (async () => {
142 if (!(await read($, isPlaying))) return
143 ticks += 1
144 if (ticks % (4 / (await read($, speed))) !== 0) return
145 const count = (await read($, frames)).length
146 const at = await read($, cursor)
147 if (at >= count - 1) {
148 await update($, isPlaying, () => false)
149 return
150 }
151 await update($, cursor, n => n + 1)
152 })()
153 })
154
155 return next(e)
156 })
157
158 on('command.run', { command: 'replay' }, async $ => {
159 await update($, view, () => 'list')
160 await $.ui.open({ id: PANE, title: TITLE })
161 await loadList($)
162
163 return { text: 'Replay Theater opened. Pick a session to replay.' }
164 })
165
166 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167 const { Box, Text, Button } = $.ui.resolve(e)
168 const columns = Math.max(20, e.props.bodyColumns - 2)
169 const rows = Math.max(10, (e.viewport?.rows ?? 30) - 4)
170 const current: View = await read($, view)
171 const message = await read($, note)
172
173 if (current === 'list') {
174 const list = await read($, sessions)
175 const now = await $.clock.now()
176
177 return (
178 <Box flexDirection="column" gap={1}>
179 <Box gap={2}>
180 <Text bold>Pick a session to replay</Text>
181 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => loadList($)} />
182 </Box>
183 {message !== null && <Text dimColor>{message}</Text>}
184 <Box flexDirection="column">
185 {list.map(one => (
186 <Box key={`row-${one.id}`} gap={1}>
187 <Button
188 key={`s-${one.id}`}
189 label={clip(one.title, 1, Math.max(10, columns - 28))[0] ?? one.id}
190 plain
191 onPress={() => openSession($, one)}
192 />
193 <Text dimColor>
194 {`${ago(one.mtimeMs, now)} · ${sizeOf(one.size)}${one.isCurrent ? ' · this session' : ''}`}
195 </Text>
196 </Box>
197 ))}
198 </Box>
199 </Box>
200 )
201 }
202
203 const all = await read($, frames)
204 const at = await read($, cursor)
205 const playing = await read($, isPlaying)
206 const pace = await read($, speed)
207 const name = await read($, title)
208 const frame = all[at]
209
210 // The stage: earlier frames as one-line context, the current one in full.
211 const stageRows = Math.max(4, rows - 8)
212 const currentLines = frame === undefined ? [] : clip(frame.text || '(empty)', Math.max(3, stageRows - 4), columns)
213 const contextRows = Math.max(0, stageRows - currentLines.length - 1)
214 const earlier = all.slice(Math.max(0, at - contextRows), at)
215
216 return (
217 <Box flexDirection="column" gap={1}>
218 <Box flexDirection="column">
219 <Text bold wrap="truncate-end">{name || 'Untitled session'}</Text>
220 <Text dimColor>
221 Frame {all.length === 0 ? 0 : at + 1} of {all.length}
222 {frame?.at ? ` · ${when(frame.at)}` : ''} · {pace}x
223 </Text>
224 </Box>
225 {message !== null && <Text color="warning">{message}</Text>}
226 {all.length === 0 ? (
227 <Text dimColor>This session has nothing to replay.</Text>
228 ) : (
229 <Box flexDirection="column">
230 {earlier.map(one => (
231 <Text dimColor wrap="truncate-end">
232 {LABEL[one.kind].name}
233 {one.tool ? ` ${one.tool}` : ''}: {one.text.split('\n')[0]}
234 </Text>
235 ))}
236 {frame !== undefined && (
237 <Box flexDirection="column" marginTop={earlier.length > 0 ? 1 : 0}>
238 <Text bold color={LABEL[frame.kind].color}>
239 {LABEL[frame.kind].name}
240 {frame.tool ? ` · ${frame.tool}` : ''}
241 </Text>
242 {currentLines.map(line => (
243 <Text>{line === '' ? ' ' : line}</Text>
244 ))}
245 </Box>
246 )}
247 </Box>
248 )}
249 <Box gap={1} flexWrap="wrap">
250 <Button key="start" label="|<" hotkey="h" onPress={() => update($, cursor, () => 0)} />
251 <Button key="back" label="< Back" hotkey="b" onPress={() => step($, -1)} />
252 <Button
253 key="play"
254 variant="primary"
255 label={playing ? 'Pause' : 'Play'}
256 hotkey="p"
257 onPress={() => togglePlay($)}
258 />
259 <Button key="next" label="Next >" hotkey="n" onPress={() => step($, 1)} />
260 <Button key="end" label=">|" hotkey="e" onPress={() => step($, all.length)} />
261 <Button
262 key="speed"
263 label={`Speed ${pace}x`}
264 hotkey="s"
265 onPress={() => update($, speed, s => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length] ?? 1)}
266 />
267 <Button
268 key="list"
269 label="Sessions"
270 hotkey="l"
271 onPress={async () => {
272 await update($, isPlaying, () => false)
273 await update($, view, () => 'list')
274 await loadList($)
275 }}
276 />
277 </Box>
278 </Box>
279 )
280 })
281}
282hooks/parse.ts 123 lines1import type { Frame } from '../types'
2
3const MAX_TEXT = 2000
4const MAX_FRAMES = 3000
5
6type Block = {
7 type?: string
8 text?: string
9 name?: string
10 input?: Record<string, unknown>
11 content?: unknown
12 is_error?: boolean
13}
14
15type Entry = {
16 type?: string
17 isMeta?: boolean
18 isSidechain?: boolean
19 timestamp?: string
20 message?: { content?: string | Block[] }
21}
22
23// The folder Claude Code keeps a project's transcripts in, named after its root.
24export const projectFolderName = (root: string): string =>
25 root.replace(/[^a-zA-Z0-9]/g, '-')
26
27const cut = (text: string): string =>
28 text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text
29
30// Rows the engine injects rather than the person typed.
31const isNoise = (text: string): boolean => {
32 const t = text.trimStart()
33 return (
34 t === '' ||
35 t.startsWith('<system-reminder>') ||
36 t.startsWith('<local-command-') ||
37 t.startsWith('Caveat: The messages below') ||
38 t.startsWith('[Request interrupted')
39 )
40}
41
42// "/name args" for a slash command row, the text itself otherwise.
43const promptText = (text: string): string => {
44 const name = /<command-name>([^<]*)<\/command-name>/.exec(text)?.[1]
45 if (name === undefined) return text.trim()
46 const args = /<command-args>([^<]*)<\/command-args>/.exec(text)?.[1] ?? ''
47 return `${name.startsWith('/') ? name : `/${name}`} ${args}`.trim()
48}
49
50const toolSummary = (input: Record<string, unknown> | undefined): string => {
51 if (input === undefined) return ''
52 for (const key of ['description', 'command', 'file_path', 'pattern', 'url', 'query', 'prompt']) {
53 const value = input[key]
54 if (typeof value === 'string' && value !== '') return value
55 }
56 const first = Object.values(input).find(v => typeof v === 'string')
57 return typeof first === 'string' ? first : ''
58}
59
60const resultText = (content: unknown): string => {
61 if (typeof content === 'string') return content
62 if (!Array.isArray(content)) return ''
63 return content
64 .map(b => (b && typeof b === 'object' && 'text' in b ? String((b as Block).text ?? '') : '[image]'))
65 .join('\n')
66}
67
68const parseLine = (line: string): Entry | undefined => {
69 try {
70 const value: unknown = JSON.parse(line)
71 return value && typeof value === 'object' ? (value as Entry) : undefined
72 } catch {
73 return undefined
74 }
75}
76
77// Turns a transcript's JSONL into the frames the stage plays: the person's
78// prompts, Claude's replies, its tool calls and their results.
79export const parseTranscript = (jsonl: string): Frame[] => {
80 const frames: Frame[] = []
81 for (const line of jsonl.split('\n')) {
82 if (frames.length >= MAX_FRAMES) break
83 const entry = parseLine(line)
84 if (entry === undefined || entry.isMeta || entry.isSidechain) continue
85 if (entry.type !== 'user' && entry.type !== 'assistant') continue
86 const at = entry.timestamp
87 const content = entry.message?.content
88 if (typeof content === 'string') {
89 if (entry.type === 'user' && !isNoise(content)) {
90 frames.push({ kind: 'prompt', text: cut(promptText(content)), at })
91 }
92 continue
93 }
94 for (const block of content ?? []) {
95 if (block.type === 'text' && typeof block.text === 'string') {
96 if (entry.type === 'assistant') {
97 if (block.text.trim() !== '') frames.push({ kind: 'reply', text: cut(block.text.trim()), at })
98 } else if (!isNoise(block.text)) {
99 frames.push({ kind: 'prompt', text: cut(promptText(block.text)), at })
100 }
101 } else if (block.type === 'tool_use') {
102 frames.push({ kind: 'tool', tool: block.name ?? 'tool', text: cut(toolSummary(block.input)), at })
103 } else if (block.type === 'tool_result') {
104 const text = resultText(block.content).trim()
105 frames.push({ kind: 'result', text: cut(block.is_error ? `Error: ${text}` : text || '(no output)'), at })
106 }
107 }
108 }
109 return frames
110}
111
112// The first prompt the person typed, as the session's title.
113export const titleOf = (jsonl: string): string => {
114 for (const line of jsonl.split('\n')) {
115 const entry = parseLine(line)
116 if (entry?.type === 'summary' && typeof (entry as { summary?: unknown }).summary === 'string') {
117 return (entry as { summary: string }).summary
118 }
119 }
120 const first = parseTranscript(jsonl).find(f => f.kind === 'prompt')
121 return first?.text.split('\n')[0] ?? '(no prompt)'
122}
123types/index.d.ts 29 lines1export type FrameKind = 'prompt' | 'reply' | 'tool' | 'result'
2
3export type Frame = { kind: FrameKind; text: string; tool?: string; at?: string }
4
5export type SessionInfo = {
6 id: string
7 title: string
8 mtimeMs: number
9 size: number
10 isCurrent: boolean
11}
12
13export type View = 'list' | 'stage'
14
15declare module 'claude-code' {
16 interface PluginState {
17 'replay-theater': {
18 view: View
19 sessions: SessionInfo[]
20 title: string
21 frames: Frame[]
22 cursor: number
23 isPlaying: boolean
24 speed: number
25 note: string | null
26 }
27 }
28}
29