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.

Claude Code mods.
/plugin marketplace add navin0812/claude-mods
/plugin install blast-radius@claude-mods
/plugin install replay-theater@claude-mods
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.
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.
hooks/register.tsx 191 lines1import { 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}
191types/index.d.ts 8 lines1export 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