Pane that shows how far this session's changes reach: files touched, risky commands, a running risk score.

A pane that shows how far this session's changes reach: the files edited, the directories touched, risky shell commands (recursive deletes, force pushes, remote scripts) and a running LOW, MEDIUM or HIGH score.
/plugin marketplace add namtrdev/claude-mods
/plugin install blast-radius@jackie-mods
Commands: /blast-radius.
Built for Claude Code mods (hook modules). Nothing here runs outside your machine: the mod reads session events and draws its own interface.
You set these when you install the mod, and can change them later in the Claude Code config menu.
openPaneAtStart, default true): Open the Blast radius pane when a session begins; off opens it only with /blast-radius.toastFrom, default 7): Show a notice for a risky command at or above this weight (1 to 9). Lower notifies more often.extraRiskyPatterns, default empty): Regular expressions for commands to count as risky (weight 5), comma separated. Example: terraform apply,kubectl deleteMIT, Copyright (c) 2026 Jackie.
Icons: Lucide (ISC). See THIRD_PARTY_NOTICES.md.
hooks/register.tsx 159 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { icon } from './icons'
5
6import type { Risky, Touch } from '../types'
7
8const PANE = 'blast-radius'
9const touched = atom({ plugin: 'blast-radius', key: 'touched' } as const, [])
10const risky = atom({ plugin: 'blast-radius', key: 'risky' } as const, [])
11const commands = atom({ plugin: 'blast-radius', key: 'commands' } as const, 0)
12
13const WRITERS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
14const RULES: { re: RegExp; reason: string; weight: number }[] = [
15 { re: /\brm\s+(-\w*[rf]\w*\s+)+/, reason: 'recursive delete', weight: 8 },
16 { re: /\bgit\s+push\b.*(--force|-f\b)/, reason: 'force push', weight: 9 },
17 { re: /\bgit\s+(reset\s+--hard|clean\s+-\w*f)/, reason: 'discards work', weight: 7 },
18 { re: /\bgit\s+push\b/, reason: 'publishes commits', weight: 4 },
19 { re: /\b(sudo|chmod|chown)\b/, reason: 'privileged change', weight: 6 },
20 { re: /\b(curl|wget)\b.*\|\s*(sh|bash)/, reason: 'runs remote script', weight: 9 },
21 { re: /\b(npm|pnpm|yarn|pip)\s+(install|add|i)\b/, reason: 'installs packages', weight: 3 },
22 { re: /\bdrop\s+(table|database)\b/i, reason: 'drops data', weight: 9 },
23 { re: />\s*[^\s&>|]+/, reason: 'writes via redirect', weight: 2 },
24]
25
26const filePath = (e: object): string | undefined => {
27 const input = e as Record<string, unknown>
28 const path = input.file_path ?? input.notebook_path
29 return typeof path === 'string' ? path : undefined
30}
31
32const score = (files: Touch[], bad: Risky[]) =>
33 files.length + bad.reduce((sum, one) => sum + one.weight, 0)
34
35const level = (n: number) => (n >= 15 ? 'HIGH' : n >= 6 ? 'MEDIUM' : 'LOW')
36
37const parse = (raw: unknown) =>
38 String(raw ?? '')
39 .split(',')
40 .map(one => one.trim())
41 .filter(one => one !== '')
42 .flatMap(one => {
43 try {
44 return [new RegExp(one)]
45 } catch {
46 return []
47 }
48 })
49
50export const register: Register = (on, options) => {
51 const extraRules = parse(options.extraRiskyPatterns).map(re => ({ re, reason: 'your pattern', weight: 5 }))
52 const toastFrom = Number(options.toastFrom ?? 7)
53
54 on('session.start', async ($, e, next) => {
55 await $.command.register({
56 name: 'blast-radius',
57 description: 'Show how far this session’s changes reach',
58 })
59
60 if (options.openPaneAtStart !== false) {
61 void $.ui.open({ id: PANE, title: 'Blast radius' })
62 }
63
64 return next(e)
65 })
66
67 on('command.run', { command: 'blast-radius' }, async $ => {
68 await $.ui.open({ id: PANE, title: 'Blast radius' })
69
70 return { text: 'Blast radius pane opened.' }
71 })
72
73 on('tool.call', async ($, e, next) => {
74 if (WRITERS.has(e.tool)) {
75 const path = filePath(e)
76 if (path !== undefined) {
77 await update($, touched, list => {
78 const hit = list.find(one => one.path === path)
79 return hit
80 ? list.map(one => (one === hit ? { ...one, count: one.count + 1 } : one))
81 : [...list, { path, tool: e.tool, count: 1 }]
82 })
83 }
84 } else if (e.tool === 'Bash') {
85 const command = String((e as Record<string, unknown>).command ?? '')
86 await update($, commands, n => n + 1)
87 const rule = [...RULES, ...extraRules].filter(r => r.re.test(command)).sort(
88 (a, b) => b.weight - a.weight,
89 )[0]
90 if (rule !== undefined) {
91 await update($, risky, list =>
92 [...list, { command: command.slice(0, 120), reason: rule.reason, weight: rule.weight }].slice(-50),
93 )
94 if (rule.weight >= toastFrom) {
95 $.ui.toast(`Blast radius: ${rule.reason}`)
96 }
97 }
98 }
99
100 const [files, bad] = [await read($, touched), await read($, risky)]
101 const n = score(files, bad)
102 $.ui.status(n === 0 ? undefined : `blast ${level(n)} · ${files.length} files`)
103
104 return next(e)
105 })
106
107 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108 const ui = $.ui.resolve(e)
109 const { Box, Button, Text } = ui
110 const hasSvg = ui.Svg !== undefined
111 const [files, bad, ran] = [await read($, touched), await read($, risky), await read($, commands)]
112 const n = score(files, bad)
113 const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 24) - 8) / 2))
114 const dirs = new Set(files.map(one => one.path.split('/').slice(0, -1).join('/')))
115
116 return (
117 <Box flexDirection="column">
118 <Text bold>
119 Radius: {level(n)} (score {n})
120 </Text>
121 <Text dimColor>
122 {files.length} files · {dirs.size} dirs · {ran} shell commands · {bad.length} risky
123 </Text>
124 <Text bold>Files changed</Text>
125 {files.length === 0 && <Text dimColor>None yet.</Text>}
126 {files.slice(-room).map(one => (
127 <Text>
128 {one.tool} {one.path}
129 {one.count > 1 ? ` ×${one.count}` : ''}
130 </Text>
131 ))}
132 <Text bold>Risky commands</Text>
133 {bad.length === 0 && <Text dimColor>None.</Text>}
134 {bad.slice(-room).map(one => (
135 <Box gap={1}>
136 {hasSvg && icon(ui, 'triangle-alert', one.weight >= 7 ? '#ff7a7a' : '#ffe066', '')}
137 <Text color={one.weight >= 7 ? 'red' : 'yellow'}>
138 [{one.reason}] {one.command}
139 </Text>
140 </Box>
141 ))}
142 <Box gap={1}>
143 {hasSvg && icon(ui, 'rotate-ccw', '#9ca3af', '')}
144 <Button
145 key="reset"
146 label="Reset"
147 onPress={async () => {
148 await update($, touched, () => [])
149 await update($, risky, () => [])
150 await update($, commands, () => 0)
151 $.ui.status(undefined)
152 }}
153 />
154 </Box>
155 </Box>
156 )
157 })
158}
159hooks/icons.tsx 25 lines1// Icons from Lucide (https://lucide.dev, ISC license), lucide-static 0.469.0.
2// Svg draws on the desktop, editor and mobile surfaces; the terminal has none, so a glyph stands in.
3const PATHS = {
4 'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /> <path d="M3 3v5h5" />',
5 'triangle-alert': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" />',
6 'trash-2': '<path d="M3 6h18" /> <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> <line x1="10" x2="10" y1="11" y2="17" /> <line x1="14" x2="14" y1="11" y2="17" />',
7}
8
9export type IconName = keyof typeof PATHS
10
11export const svg = (name: IconName, color: string, size: number) =>
12 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</svg>`
13
14// eslint-disable-next-line @typescript-eslint/no-explicit-any
15export type Ui = { Svg?: any; Text: any }
16
17export const icon = (ui: Ui, name: IconName, color: string, glyph: string, size = 16) => {
18 const { Svg, Text } = ui
19 return Svg === undefined ? (
20 <Text color={color}>{glyph}</Text>
21 ) : (
22 <Svg source={svg(name, color, size)} alt={name} width={size} height={size} />
23 )
24}
25types/index.d.ts 9 lines1export type Touch = { path: string; tool: string; count: number }
2export type Risky = { command: string; reason: string; weight: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'blast-radius': { touched: Touch[]; risky: Risky[]; commands: number }
7 }
8}
9