SLOPSHOPPER

blast-radius

Holds destructive Bash calls (rm -rf, git reset --hard, git clean, force push, migrations) and shows the files affected in a pane until you Proceed or Cancel.

newpaneguardprocess
v0.1.0no licenseupdated 2026-10-03navin0812/claude-mods/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ blast-radius ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing held. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by blast-radius: Blast Radius: user did not approv │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blast-radius
Nothing held.
README

claude-mods

Claude Code mods.

/plugin marketplace add navin0812/claude-mods
/plugin install blast-radius@claude-mods
/plugin install replay-theater@claude-mods

blast-radius

Holds Bash calls Claude makes that are destructive (rm -rf, git reset --hard, git clean -f, force push, DB migrations), opens a pane with the command, what it would do and the files affected, and runs it only on an explicit Proceed. Cancel, Esc, an interrupt or an error all deny the call.

Limits: only Bash calls Claude makes are held, not commands you run yourself with !; command detection splits on &&, ;, ||, | only.

replay-theater

Records every Edit and Write Claude makes during a turn (file, text before and after). When the turn ends a hint appears above the prompt; run /replay to step through the edits one diff at a time with a numbered strip and Prev, Next and Close buttons.

Limits: the r hotkey only fires while the band above the prompt has focus (ctrl+x tab or a click), so /replay is the reliable way in; the diff trims common head/tail lines rather than running a full LCS.

Source 2 files
hooks/register.tsx 191 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Pending } from '../types'
5
6const PANE = 'blast-radius'
7const CAP = 200
8const pending = atom({ plugin: 'blast-radius', key: 'pending' } as const, null)
9type Choice = 'proceed' | 'cancel'
10// the answer travels through this file: a pending `$.process.run` waits on it for free,
11// where a sleep loop would burn the hook's 10s budget (ponytail: 10 min ceiling, then deny)
12const answerFile = (id: string) => `/tmp/blast-radius-${id.replace(/[^\w-]/g, '')}.answer`
13
14export type Kind = 'rm' | 'reset' | 'clean' | 'push' | 'migrate'
15export type Hit = { kind: Kind; argv: string[] }
16
17const MIGRATE =
18  /\b(prisma\s+(migrate\s+(dev|deploy|reset)|db\s+push)|rails\s+db:migrate|manage\.py\s+migrate|alembic\s+(upgrade|downgrade)|knex\s+migrate|sequelize\s+db:migrate|drizzle-kit\s+(push|migrate))\b/
19
20// ponytail: split on && ; || | only, no real shell parsing; quoted separators / subshells slip through
21export function classify(command: string): Hit | null {
22  for (const seg of command.split(/&&|\|\||;|\|/)) {
23    const argv = seg.trim().split(/\s+/).filter(Boolean)
24    const [bin, sub] = argv
25    const flags = argv.filter(a => /^-[a-zA-Z]+$/.test(a)).join('')
26    if (bin === 'rm' && /r|R/.test(flags) && /f/.test(flags) || (bin === 'rm' && argv.includes('--recursive') && argv.includes('--force')))
27      return { kind: 'rm', argv: argv.slice(1).filter(a => !a.startsWith('-')) }
28    if (bin === 'git' && sub === 'reset' && argv.includes('--hard')) return { kind: 'reset', argv }
29    if (bin === 'git' && sub === 'clean' && (/f/.test(flags) || argv.includes('--force'))) return { kind: 'clean', argv }
30    if (bin === 'git' && sub === 'push' && argv.some(a => /^(--force(-with-lease)?(=.*)?|-f|\+\S+)$/.test(a) || /^-[a-zA-Z]*f/.test(a)))
31      return { kind: 'push', argv }
32    if (MIGRATE.test(seg)) return { kind: 'migrate', argv }
33  }
34  return null
35}
36
37const WOULD: Record<Kind, string> = {
38  rm: 'delete',
39  reset: 'discard uncommitted changes to',
40  clean: 'delete untracked',
41  push: 'overwrite the remote branch, dropping commits',
42  migrate: 'apply migrations to the database; migration files',
43}
44
45export const register: Register = on => {
46  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
47    const hit = classify(e.command)
48    if (!hit) return next(e)
49
50    const lines = await radius($, hit)
51    const files = lines.slice(0, CAP)
52    const p: Pending = {
53      id: e.tool_use_id,
54      kind: hit.kind,
55      command: e.command,
56      would: WOULD[hit.kind],
57      files,
58      more: lines.length - files.length,
59      size: hit.kind === 'rm' ? await size($, hit.argv) : undefined,
60      paths: hit.kind === 'rm' ? hit.argv.join(' ') : undefined,
61    }
62    // runs only on an explicit Proceed; anything else (Cancel, Esc, interrupt, error) denies
63    const choice = await hold($, p, next.signal)
64    return choice === 'proceed' ? next(e) : { deny: `Blast Radius: user did not approve \`${e.command}\`` }
65  }).catch(($, e, next) =>
66    // fail closed: a throw or overrun would otherwise let the call run unheld
67    classify(e.command) ? { deny: `Blast Radius: could not hold this call, blocked to be safe (${next.error.kind}: ${next.error.message ?? 'no message'})` } : undefined,
68  )
69
70  on('ui.close', { id: PANE }, async ($, e, next) => {
71    const p = await read($, pending)
72    if (p) await $.fs.write(answerFile(p.id), 'cancel') // closed by hand without an answer; a real answer already wrote the file first
73    return next(e)
74  })
75
76  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
77    const { Box, Text, Button } = $.ui.resolve(e)
78    const p = await read($, pending)
79    if (!p) return <Text dimColor>Nothing held.</Text>
80    const room = Math.max(1, (e.viewport?.rows ?? 24) - 10)
81    const shown = p.files.slice(0, room)
82    const hidden = p.files.length - shown.length + p.more
83    const n = p.files.length + p.more
84    const answer = (choice: Choice) => $.fs.write(answerFile(p.id), choice)
85
86    return (
87      <Box flexDirection="column">
88        <Text bold color="yellow">
89          ⚠ Blast Radius · {p.command.split(/\s+/).slice(0, 2).join(' ')}
90        </Text>
91        <Box>
92          <Text dimColor>Command  </Text>
93          <Text bold>{p.command}</Text>
94        </Box>
95        <Box>
96          <Text dimColor>Would    </Text>
97          <Text bold color="red">
98            {p.would} {n} file{n === 1 ? '' : 's'}
99            {p.size ? ` (${p.size})` : ''}
100          </Text>
101        </Box>
102        <Text> </Text>
103        {shown.map(f => (
104          <Text>  {f}</Text>
105        ))}
106        {hidden > 0 && <Text dimColor>  +{hidden} more</Text>}
107        {p.paths && (
108          <Text dimColor italic>
109            Paths: {p.paths}
110          </Text>
111        )}
112        <Text> </Text>
113        <Box>
114          <Button plain hotkey="1" onPress={() => answer('proceed')}>
115            Proceed
116          </Button>
117          <Text>  </Text>
118          <Button plain hotkey="2" autoFocus onPress={() => answer('cancel')}>
119            Cancel
120          </Button>
121          <Text dimColor>  Claude is waiting on your answer</Text>
122        </Box>
123      </Box>
124    )
125  })
126}
127
128async function hold($: any, p: Pending, signal: AbortSignal): Promise<Choice> {
129  const file = answerFile(p.id)
130  await update($, pending, () => p)
131  signal.addEventListener?.('abort', () => void $.fs.write(file, 'cancel'))
132  try {
133    const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true, closeOnEscape: true })
134    if (!opened.isPlaced) {
135      // pane has no room (narrow terminal): same question in the engine's own dialog
136      const n = p.files.length + p.more
137      const a = await $.ui.ask(`Blast Radius: ${p.command}\nWould ${p.would} ${n} file(s). Run it?`, ['Proceed', 'Cancel'])
138      return a === 'Proceed' ? 'proceed' : 'cancel'
139    }
140    // blocks until a button (or close, or interrupt) writes the answer file
141    const r = await $.process.run(['sh', '-c', 'until [ -s "$0" ]; do sleep 0.2; done; cat "$0"', file], { timeoutMs: 600_000 })
142    return (r.stdout as string).trim() === 'proceed' ? 'proceed' : 'cancel'
143  } catch {
144    return 'cancel' // dismissed ask, timeout, -p run: nobody said yes
145  } finally {
146    await update($, pending, () => null) // before close, so the ui.close hook skips
147    await $.ui.close({ id: PANE })
148    await $.process.run(['rm', '-f', file]).catch(() => undefined)
149  }
150}
151
152async function radius($: any, { kind, argv }: Hit): Promise<string[]> {
153  const run = async (cmd: string[]) => {
154    try {
155      const r = await $.process.run(cmd)
156      return r.exitCode === 0 ? (r.stdout as string).split('\n').filter(Boolean) : []
157    } catch {
158      return []
159    }
160  }
161  switch (kind) {
162    case 'rm':
163      return argv.length ? run(['find', ...argv, '-not', '-type', 'd']) : []
164    case 'reset':
165      return (await run(['git', 'status', '--porcelain', '--untracked-files=no'])).map(l => l.slice(3))
166    case 'clean':
167      // dry run: swap the force flag for -n
168      return (await run(['git', ...argv.slice(1).map(a => (a === '--force' ? '-n' : a.replace(/^(-[a-zA-Z]*)f/, '$1n')))])).map(l => l.replace(/^Would (remove|skip) /, ''))
169    case 'push':
170      return run(['git', 'log', '--oneline', 'HEAD..@{u}'])
171    case 'migrate': {
172      const out: string[] = []
173      for (const d of ['prisma/migrations', 'migrations', 'db/migrate', 'alembic/versions'])
174        for (const l of (await run(['ls', '-t', d])).slice(0, 20)) out.push(`${d}/${l}`)
175      return out
176    }
177  }
178}
179
180// total size of the rm targets via du -sk, as "1.1 MB"
181async function size($: any, targets: string[]): Promise<string | undefined> {
182  if (!targets.length) return undefined
183  try {
184    const r = await $.process.run(['du', '-sk', ...targets])
185    const kb = (r.stdout as string).split('\n').reduce((n, l) => n + (parseInt(l, 10) || 0), 0)
186    return kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`
187  } catch {
188    return undefined
189  }
190}
191
types/index.d.ts 8 lines
1export type Pending = { id: string; kind: string; command: string; would: string; files: string[]; more: number; size?: string; paths?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'blast-radius': { pending: Pending | null }
6  }
7}
8