SLOPSHOPPER

blast-radius

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

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-26namtrdev/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 a╭──────────────────────────╮ │ ┃ Radius: MEDIUM (score 12) │ blast-radius │ │ ┃ 3 files · 1 dirs · 4 shell commands · 1 ⏺ Read(src/auth.ts) │ Blast radius: force push │ │ ┃ risky ⎿ Read 6 lines ╰──────────────────────────╯ │ ┃ Files changed ⏺ Update(src/auth.ts) │ ┃ Edit /work/app/src/auth.ts ⎿ Added 2 lines, removed 1 line │ ┃ Write /work/app/src/audit.ts ⏺ Bash(bun test) │ ┃ Write /work/app/src/cache.ts ⎿ 3 pass, 1 fail │ ┃ Risky commands │ ┃ [force push] rm -rf build && git push ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ --force origin main │ ┃ [ Reset ] ✻ Worked for 42s · done 4:20 PM │ │ › /blast-radius │ ⎿ blast-radius: Blast radius pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ blast-radius: blast MEDIUM · 3 files

Draws

Pane · Blast radius
Radius: MEDIUM (score 12) 3 files · 1 dirs · 4 shell commands · 1 risky Files changed Edit /work/app/src/auth.ts Write /work/app/src/audit.ts Write /work/app/src/cache.ts Risky commands [force push] rm -rf build && git push --force origin main [ Reset ]
README

Blast Radius

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.

Install

/plugin marketplace add namtrdev/claude-mods
/plugin install blast-radius@jackie-mods

Use

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.

Options

You set these when you install the mod, and can change them later in the Claude Code config menu.

  • Open the pane at start (openPaneAtStart, default true): Open the Blast radius pane when a session begins; off opens it only with /blast-radius.
  • Notify from risk weight (toastFrom, default 7): Show a notice for a risky command at or above this weight (1 to 9). Lower notifies more often.
  • Extra risky commands (extraRiskyPatterns, default empty): Regular expressions for commands to count as risky (weight 5), comma separated. Example: terraform apply,kubectl delete

License

MIT, Copyright (c) 2026 Jackie.

Icons: Lucide (ISC). See THIRD_PARTY_NOTICES.md.

Source 3 files
hooks/register.tsx 159 lines
1import { 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}
159
hooks/icons.tsx 25 lines
1// 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}
25
types/index.d.ts 9 lines
1export 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