SLOPSHOPPER

replay-theater

[mod] Records the file edits Claude makes in a turn; /replay or r steps through the diffs one at a time in a pane. Based on Anthropic's claude-code-playground…

newpanebandguardcommand
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 │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▶ Replay Theater step 1 of 3 │ ⏺ Read(src/auth.ts) │ ┃ │ 1 2 3 │ ⎿ Read 6 lines │ ┃ │ src/auth.ts │ ⏺ Update(src/auth.ts) │ ┃ │ Edit +2 -1 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ - if (!claims) throw new Error('invalid… │ ⎿ 3 pass, 1 fail │ ┃ │ + if (!claims || claims.exp < Date.now(… │ │ ┃ │ + await audit('refresh', claims.sub) │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ › /replay │ ⎿ replay-theater: Replay Theater: 3 edits │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
╭──────────────────────────────────────────────────────────╮ │ ▶ Replay Theater step 1 of 3 │ │ 1 2 3 │ │ src/auth.ts │ │ Edit +2 -1 │ │ │ │ - if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) thr │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
README

claude-code

japan-da-man の Claude Code プラグイン(mods を含む)を管理するマーケットプレイス。

プラグイン

| 名前 | 内容 | | :- | :- | | japan-da-man-mods | まとめてインストール用。下のプラグインを全部入れる | | todo-mod | /todo で TODO パネルを開く。チェックを入れたタスクを Claude に依頼する | | token-weather | コンテキストの埋まり具合を天気でプロンプトの上に表示する(☀ Clear → ☁ Cloudy → ☂ Showers → ☇ Storm → ↯ Compact soon)。直近12ターンのグラフと、直前のターンの増減(▲ +98.3k last turn)付き。/weather で表示切り替え | | usage-stats | /stats で利用統計のパネルを開く。セッション数・メッセージ数・トークン数・利用日数・連続日数・よく使うモデル・日ごとのヒートマップ | | blast-radius | 危険なシェルコマンドを実行前に止め、何が消えるか・変わるかをペインに出して Proceed / Cancel で選ばせる(10分答えがなければ止める)。rm -r/-f、git reset --hard、force push、git clean、変更の破棄、git branch -D、git stash drop/clear、マイグレーション、find -delete、xargs rm。bash -c / eval の中も調べる。/blast-radius で履歴 | | replay-theater | ターンの中で Claude が行ったファイル編集を記録する。ターンが終わるとプロンプトの上に ▶ Replay: N edits が出て、r か /replay でペインを開き、編集を 1 件ずつ差分で見られる(Prev / Next / Close) | | spec-flow | 「〇〇な機能が欲しい」と話しかけるか /spec-flow:spec <要望> で、要求整理 → 影響画面確認 → 要件定義 → 機能案・UI案 を 1 段階ずつ承認しながら進める。曖昧な点はヒアリングし、影響調査と案出しはサブエージェントが行う。成果物は docs/specs/<id>/(UI 案の HTML モックは Desktop なら Artifact にも公開。--local で公開しない)。/specs でチェックリストを開き、チェックで承認・修正依頼もできる | | dev-flow | 汎用の開発フロー。skills plan(小さなタスクに分けて承認をもらってから着手)・tdd(RED → GREEN → REFACTOR)・debug(再現 → 仮説 → 検証で原因を確かめてから直す)・review(重要度つきの差分レビュー)。サブエージェント code-explorer(横断調査)・test-runner(テスト結果の要約)・reviewer(別コンテキストでレビュー、修正はしない)が長い出力をメインに持ち込まない | | dev-extras | 他の OSS から選んで取り込んだ skill(すべて MIT、出典は各 README と licenses/)。ponytail(最小の実装に絞る)・ponytail-review(作り込みすぎだけを探す)・grill-me(質問攻めで計画を詰める)・handoff(バックグラウンドエージェントに引き継ぐ)・verification-before-completion(検証してから完了と言う)・security-audit(Cloudflare 製のセキュリティ監査) | | mod-dev | skill add-mod。「〇〇する mod を作って」で雛形・テスト・登録まで行う。marketplace リポジトリでは marketplace.json / bundle / README も更新し、それ以外では単体の mod を作る |

インストール

claude plugin marketplace add japan-da-man/claude-code
claude plugin install japan-da-man-mods@japan-da-man

1つずつ入れる場合は claude plugin install <名前>@japan-da-man。

更新:

claude plugin marketplace update japan-da-man
claude plugin update <名前>@japan-da-man

bundle に増えたプラグインを入れるときは、update → install の順で実行する(update だけだと増えた分は入らず、bundle が failed to load になる):

claude plugin marketplace update japan-da-man
claude plugin update japan-da-man-mods@japan-da-man
claude plugin install japan-da-man-mods@japan-da-man

セッション内なら update のあと /reload-plugins でもよい。

開発

インストールせずに作業中のディレクトリを読み込む(保存するとホットリロードされる):

claude --plugin-dir ./plugins/todo-mod

チェック:

claude plugin validate .
claude plugin validate ./plugins/todo-mod

テスト(tests/*.test.ts があるプラグイン):

cd plugins/<name> && claude plugin test

プラグインの追加

mod-dev を入れた状態でこのリポジトリを開き「〇〇する mod を追加して」と頼むと、skill add-mod が下の手順(雛形・テスト・marketplace / bundle / README 更新・validate)をまとめてやる。手でやる場合:

  1. plugins/<name>/ に .claude-plugin/plugin.json と中身を置く
  2. .claude-plugin/marketplace.json の plugins にエントリを追加する(name は plugin.json と同じにする)
  3. まとめて入るようにするなら plugins/japan-da-man-mods/.claude-plugin/plugin.json の dependencies にも名前を足す

plugin.json に version を書いていないので、コミットごとに新しいバージョンとして扱われる。

mods は Claude Code v2.1.287 以降が必要。

公式 mod をもとにしたもの

token-weather・blast-radius・replay-theater は、Anthropic の claude-code-playground にある同名の mod(Apache-2.0)をもとに手を加えたもの。各プラグインに LICENSE を同梱し、各 hooks/register.js の先頭に元のコードの著作権表示と、こちらで変えた点を書いている。

Source 2 files
hooks/register.js 287 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Replay Theater: records the file edits Claude makes in a turn, then lets you
5// step through them in a pane, one diff at a time.
6// Based on mods/replay-theater in anthropics/claude-code-playground (Apache-2.0, see LICENSE).
7//
8// japan-da-man による変更:
9// - 記録したステップと見ている位置を $.state に置く(プラグインを読み込み直しても消えない)
10// - 成功した編集だけ記録する(拒否されたり失敗したりした編集は記録しない)
11// - 帯に他の mod が描くものを残す(Token Weather などと共存する)
12// - 描画サイトの値は e.props から読む
13
14import { atom, read, update } from 'claude-code'
15
16const PANE_ID = 'replay-theater'
17const MAX_DIFF_LINES = 12
18const MAX_LCS_LINES = 400
19const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
20
21// 直前に編集があったターンのステップと、ペインで見ている位置
22const replayAtom = atom({ plugin: 'replay-theater', key: 'replay' }, { steps: [], index: 0 })
23// 今のターンのステップ(ターンが終わったら replay に移す)
24let pending = []
25// ペインを開いているか。狭い画面でペインを置けないときは帯に描く
26const view = { isOpen: false, inBand: false }
27
28export function relPath(cwd, path) {
29  if (!path) return '(unknown file)'
30  if (cwd && path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
31  return path
32}
33
34function splitLines(text) {
35  if (text === undefined || text === null || text === '') return []
36  const lines = String(text).split('\n')
37  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
38  return lines
39}
40
41// 行単位の差分。小さいものは LCS で変わっていない行も文脈として残し、大きいものは全部削除→全部追加にする
42export function diffLines(oldText, newText) {
43  const a = splitLines(oldText)
44  const b = splitLines(newText)
45  if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
46    return [...a.map((t) => ({ op: '-', t })), ...b.map((t) => ({ op: '+', t }))]
47  }
48  const n = a.length
49  const m = b.length
50  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
51  for (let i = n - 1; i >= 0; i--) {
52    for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
53  }
54  const out = []
55  let i = 0
56  let j = 0
57  while (i < n && j < m) {
58    if (a[i] === b[j]) {
59      out.push({ op: ' ', t: a[i] })
60      i++
61      j++
62    } else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: '-', t: a[i++] })
63    else out.push({ op: '+', t: b[j++] })
64  }
65  while (i < n) out.push({ op: '-', t: a[i++] })
66  while (j < m) out.push({ op: '+', t: b[j++] })
67  return trimContext(out)
68}
69
70// 変更の前後 1 行だけ文脈を残す。長いファイルの Write でも変わった行が見える
71export function trimContext(lines) {
72  if (!lines.some((l) => l.op !== ' ')) return lines.slice(0, MAX_DIFF_LINES)
73  const changed = (l) => l !== undefined && l.op !== ' '
74  const keep = lines.map((l, k) => changed(l) || changed(lines[k - 1]) || changed(lines[k + 1]))
75  const out = []
76  let skipped = false
77  lines.forEach((l, k) => {
78    if (keep[k]) {
79      if (skipped && out.length) out.push({ op: '~', t: '⋯' })
80      out.push(l)
81      skipped = false
82    } else {
83      skipped = true
84    }
85  })
86  return out
87}
88
89export function countChanges(diff) {
90  let add = 0
91  let del = 0
92  for (const l of diff) {
93    if (l.op === '+') add++
94    else if (l.op === '-') del++
95  }
96  return { add, del }
97}
98
99// 1 回のツール呼び出しを 1 つ以上のステップにする。before は Write の前のファイルの中身
100export function stepsFor(e, cwd, before) {
101  const file = relPath(cwd, e.file_path)
102  if (e.tool === 'Edit') {
103    return [{ tool: 'Edit', file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? 'replace all' : '' }]
104  }
105  if (e.tool === 'MultiEdit' && Array.isArray(e.edits)) {
106    return e.edits.map((ed, k) => ({ tool: 'MultiEdit', file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}` }))
107  }
108  if (e.tool === 'Write') {
109    return [{ tool: 'Write', file, diff: diffLines(before ?? '', e.content), note: before === null ? 'new file' : 'rewrite' }]
110  }
111  return []
112}
113
114// Write の前のファイルの中身。ないか読めなければ null(新しいファイルとして扱う)
115async function readBefore($, path) {
116  try {
117    return path && (await $.fs.exists(path)) ? await $.fs.read(path) : null
118  } catch {
119    return null
120  }
121}
122
123export function hintText(n) {
124  return `▶ Replay: ${n} edit${n === 1 ? '' : 's'} (press r)`
125}
126
127async function openReplay($) {
128  const { steps } = await read($, replayAtom)
129  if (!steps.length) return false
130  await update($, replayAtom, (s) => ({ ...s, index: 0 }))
131  view.isOpen = true
132  // ペインはほかにキーボードを持つものがないときだけキーを受け取る。先に帯を消してから開く
133  $.ui.invalidate('ui.render')
134  await $.clock.sleep(200)
135  const rows = Math.min(MAX_DIFF_LINES + 8, 22)
136  const placed = await $.ui.open({ id: PANE_ID, title: 'Replay Theater', focus: true, closeOnEscape: true, rows, columns: 58 })
137  // ペインを置く場所がない(狭いターミナル)ときは帯に描く
138  view.inBand = placed?.isPlaced === false
139  $.ui.invalidate('ui.render')
140  return true
141}
142
143// 1 ステップずつ見る画面。ペインに描くか、ペインを置けないときは帯に描く
144async function replayView($, e, inBand) {
145  const { Box, Text, Button } = $.ui.resolve(e)
146  const { steps, index } = await read($, replayAtom)
147  const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.props.maxRows || 20) - 7)) : MAX_DIFF_LINES
148  const total = steps.length
149  if (!total) return Text({ dimColor: true, children: 'No edits to replay.' })
150  const k = Math.max(0, Math.min(index, total - 1))
151  const step = steps[k]
152  const width = Math.max(40, (e.props.bodyColumns || 100) - 4)
153  const { add, del } = countChanges(step.diff)
154  const shown = step.diff.slice(0, maxDiff)
155
156  const diffRows = shown.map((l, n) => {
157    const color = l.op === '+' ? 'green' : l.op === '-' ? 'red' : undefined
158    const line = `${l.op === '~' ? ' ' : l.op} ${l.t}`.slice(0, width)
159    return Text({ key: `d${n}`, color, dimColor: l.op === ' ' || l.op === '~', wrap: 'truncate-end', children: line })
160  })
161  if (step.diff.length > maxDiff) diffRows.push(Text({ key: 'more', dimColor: true, children: `  … ${step.diff.length - maxDiff} more lines` }))
162  if (!shown.length) diffRows.push(Text({ key: 'empty', dimColor: true, children: '  (no line changes)' }))
163
164  // ステップの並び。今のステップを反転表示する
165  const strip = steps.map((s, n) => Text({ key: `s${n}`, inverse: n === k, color: n === k ? 'cyan' : undefined, dimColor: n !== k, children: ` ${n + 1} ` }))
166
167  const go = (to) => update($, replayAtom, (s) => ({ ...s, index: Math.max(0, Math.min(to, s.steps.length - 1)) }))
168  const close = () => {
169    view.isOpen = false
170    if (!view.inBand) $.ui.close({ id: PANE_ID })
171    view.inBand = false
172    $.ui.invalidate('ui.render')
173  }
174
175  return Box({
176    flexDirection: 'column',
177    borderStyle: 'round',
178    borderColor: 'magenta',
179    paddingX: 1,
180    children: [
181      Box({
182        flexDirection: 'row',
183        justifyContent: 'space-between',
184        children: [Text({ bold: true, color: 'magenta', children: '▶ Replay Theater' }), Text({ bold: true, children: `step ${k + 1} of ${total}` })],
185      }),
186      Box({ flexDirection: 'row', children: strip }),
187      Text({ bold: true, color: 'cyan', wrap: 'truncate-start', children: step.file }),
188      Box({
189        flexDirection: 'row',
190        gap: 2,
191        children: [
192          Text({ dimColor: true, children: `${step.tool}${step.note ? ' · ' + step.note : ''}` }),
193          Text({ color: 'green', children: `+${add}` }),
194          Text({ color: 'red', children: `-${del}` }),
195        ],
196      }),
197      Box({ flexDirection: 'column', marginTop: 1, children: diffRows }),
198      Box({
199        flexDirection: 'row',
200        gap: 2,
201        marginTop: 1,
202        children: [
203          Button({ key: 'prev', label: '◀ Prev', hotkey: 'p', onPress: () => go(k - 1) }),
204          Button({ key: 'next', label: 'Next ▶', hotkey: 'n', autoFocus: true, onPress: () => go(k + 1) }),
205          Button({ key: 'close', label: 'Close', hotkey: 'c', onPress: close }),
206        ],
207      }),
208    ],
209  })
210}
211
212export function register(on) {
213  on('session.start', async ($, e, next) => {
214    const r = await next(e)
215    await $.command.register({ name: 'replay', description: 'Replay Theater: step through the last turn\'s file edits', immediate: true })
216    return r
217  })
218
219  on('command.run', { command: 'replay' }, async ($) => {
220    const opened = await openReplay($)
221    const { steps } = await read($, replayAtom)
222    return { text: opened ? `Replay Theater: ${steps.length} edits` : 'Replay Theater: no edits in the last turn.' }
223  })
224
225  // 編集を記録して、そのまま実行させる。記録が失敗しても編集は止めない。成功した編集だけ残す
226  on('tool.call', async ($, e, next) => {
227    if (!EDIT_TOOLS.has(e.tool)) return next(e)
228    let steps = []
229    try {
230      const before = e.tool === 'Write' ? await readBefore($, e.file_path) : null
231      steps = stepsFor(e, await $.session.cwd(), before)
232    } catch {
233      steps = []
234    }
235    const r = await next(e)
236    if (r && !r.deny && !r.isError) pending.push(...steps)
237    return r
238  })
239
240  on('turn.start', async ($, e, next) => {
241    if (!e.agentId) pending = []
242    return next(e)
243  })
244
245  // メインのターンが終わったら、そのターンの編集を replay にする。編集のないターンでは前の replay を残す
246  on('turn.complete', async ($, e, next) => {
247    const r = await next(e)
248    if (e.agentId || !pending.length) return r
249    const steps = pending
250    pending = []
251    await update($, replayAtom, () => ({ steps, index: 0 }))
252    return r
253  })
254
255  // プロンプトの上: ヒントとペインを開くボタン
256  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
257    if (view.isOpen && view.inBand) return replayView($, e, true)
258    const { steps } = await read($, replayAtom)
259    if (!steps.length || view.isOpen || e.props.hasSurvey) return next(e)
260    const { Box, Text, Button } = $.ui.resolve(e)
261    const line = Box({
262      flexDirection: 'row',
263      gap: 2,
264      paddingX: 1,
265      children: [
266        Text({ color: 'magenta', bold: true, children: hintText(steps.length) }),
267        Button({ key: 'open-replay', label: 'Replay', hotkey: 'r', onPress: () => openReplay($) }),
268      ],
269    })
270    // 他の mod が帯に描くものも残す
271    const rest = await next(e)
272    return rest ? Box({ flexDirection: 'column', children: [line, rest] }) : line
273  })
274
275  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
276    if (e.requestId !== PANE_ID) return next(e)
277    return replayView($, e, false)
278  })
279
280  // Esc などでペインが閉じられたら、こちらの状態も合わせる
281  on('ui.close', async ($, e, next) => {
282    if (e.id === PANE_ID) view.isOpen = false
283    $.ui.invalidate('ui.render')
284    return next(e)
285  })
286}
287
types/index.d.ts 17 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'replay-theater': {
4      // 直前に編集があったターンの編集(1 件 = 1 ステップ)と、ペインで見ている位置(0 始まり)
5      replay: {
6        steps: Array<{
7          tool: string
8          file: string
9          note: string
10          diff: Array<{ op: string; t: string }>
11        }>
12        index: number
13      }
14    }
15  }
16}
17