SLOPSHOPPER

replay-theater

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

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-15judioo/claude-mods/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ Pick a session to replay [ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No past sessions found for this project. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replay Theater opened. Pick a session to replay. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
Pick a session to replay [ Refresh ] No past sessions found for this project.
README

claude-mods

Mods for Claude Code: plugins that change its own interface.

ModCommandWhat it does
Agent Sankey/sankeyDraws an orchestrator session and the sessions it started as a Sankey diagram
Replay Theater/replayPlays back a past session of the current project step by step

Install

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.

Agent Sankey

Agent Sankey showing an orchestrator, five sessions it started and their outcomes

Type /sankey and the diagram draws inline in the conversation:

  • Left: the orchestrator, a session that started other sessions.
  • Middle: the sessions it started, by name, and the sessions those started, one column further right. The orchestrator's own work flows through a bar of its own.
  • Right: where each session stands: Coordinating, Active, Idle, PR open, PR merged, PR closed or Archived, each in its own colour.
  • Width: turns, so you can see where the work went.

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.

Replay Theater

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.

Developing

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

License

MIT

Source 3 files
hooks/register.tsx 282 lines
1import { 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}
282
hooks/parse.ts 123 lines
1import 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}
123
types/index.d.ts 29 lines
1export 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